diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8df49946..f8ac8a30 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index d9ab3e84..1353830f 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 '"$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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7454af88..808e1628 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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". 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 }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 05860cb3..a074e6db 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6750a327..ac20cd7c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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` diff --git a/Cargo.lock b/Cargo.lock index ba84c75f..81ecca78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 38ba10a0..47c8d417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index aa363bea..72240b5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5fabc10c..5ad6c80d 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -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. diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index 7d0ec4b2..b4bdebb3 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -117,7 +117,7 @@ fn run>( 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(); diff --git a/apps/effectcraft/src/control_server.rs b/apps/effectcraft/src/control_server.rs index e5af3999..0ae8bfab 100644 --- a/apps/effectcraft/src/control_server.rs +++ b/apps/effectcraft/src/control_server.rs @@ -27,11 +27,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { 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 } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 1b5f74f3..83015f1a 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -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, env_port: Option) } 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(); } diff --git a/apps/effectcraft/src/window.rs b/apps/effectcraft/src/window.rs new file mode 100644 index 00000000..6826edf0 --- /dev/null +++ b/apps/effectcraft/src/window.rs @@ -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, size: PhysicalSize, 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, monitors: &[Monitor], home: Option) -> 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 { + let handles: Vec<_> = window.available_monitors().collect(); + let monitors: Vec = 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) { + 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 { + 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, + saved: Option, + 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, + /// 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, saved: Option, 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 { + 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}"); + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 03f8c667..6a535121 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 37de10a5..6bb6fbc5 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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 diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fa86095b..a83e0d1e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result { } fn list_fonts(b: &mut Backend, a: &Value) -> Result { - 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."} }), &[], diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index b1cd4a60..4fc1cc81 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool { } } -/// Parse a selection: comma-separated names (optionally quoted) or `` 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 { + let mut out = vec![]; + let mut cur = String::new(); + let mut quote: Option = 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 +/// `` hashes / wildcards. pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { 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(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0a6663a5..e431ff22 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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]; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d32cfb2e..9b733fe9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index fdc3140c..88a5c01d 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -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", &[]); diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 014353fc..24c561ec 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -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 { 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]); diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 67d35378..d455ce03 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -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 { 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 = (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 { + 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 = 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)] diff --git a/crates/engine/presets/transitions.json b/crates/engine/presets/transitions.json new file mode 100644 index 00000000..386e0274 --- /dev/null +++ b/crates/engine/presets/transitions.json @@ -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 } + ] + } + ] +} diff --git a/crates/engine/presets/transitions.json.attribution b/crates/engine/presets/transitions.json.attribution new file mode 100644 index 00000000..d09c75ed --- /dev/null +++ b/crates/engine/presets/transitions.json.attribution @@ -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. diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/commands/anim_presets.rs new file mode 100644 index 00000000..e14425cb --- /dev/null +++ b/crates/engine/src/commands/anim_presets.rs @@ -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 { + serde_json::from_str::(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 { + 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 = 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 { + 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 { + 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::>()?; + 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 = Vec::new(); + let mut skipped: Vec = 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index a1060c85..c700123d 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -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 { + 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) { + 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 { 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 { 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 { 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}))); } } diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b6101c5a..9f6b7c99 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -128,7 +128,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { fn save_copy_xml(s: &mut Session, p: &Value) -> Result { 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 { 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 { } fn reveal_log(s: &mut Session, _: &Value) -> Result { - 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 { 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); + } +} diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 6ed3a16d..4bfd2b2b 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -109,6 +109,7 @@ pub fn specs() -> Vec { 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, diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index bf1fd530..3fcf9c6d 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -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 { /// 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; diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 2c4ca409..f1511558 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -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 { 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 { 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 { + 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 { let cid = comp_id(s, p)?; @@ -143,6 +165,65 @@ fn responsive_time(s: &mut Session, p: &Value) -> Result { 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 { + 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 { + 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 { + serde_json::to_value(&s.prefs.comp_presets).map_err(|e| bad("comp.presets", e.to_string())) +} + pub fn specs() -> Vec { 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 { 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), ] } diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/commands/content_fill.rs index 9ff0e157..650df5e7 100644 --- a/crates/engine/src/commands/content_fill.rs +++ b/crates/engine/src/commands/content_fill.rs @@ -405,6 +405,7 @@ pub fn specs() -> Vec { label: "Generate Fill Layer", menu: &[], shortcut: None, + alt_shortcuts: &[], params: P, enabled: has_layers, run: generate, @@ -415,6 +416,7 @@ pub fn specs() -> Vec { label: "Content-Aware Fill Layer...", menu: &["Layer", "New"], shortcut: None, + alt_shortcuts: &[], params: P, enabled: always, run: open_panel, diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index e5d2f912..7f134c6b 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// 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 { + 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 { let (cid, ids) = layers_p(s, p)?; let t = s.time(); @@ -773,7 +809,7 @@ fn original_path(s: &Session) -> Option { fn edit_original(s: &mut Session, _: &Value) -> Result { 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 { 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"))); + } +} diff --git a/crates/engine/src/commands/expr_tools.rs b/crates/engine/src/commands/expr_tools.rs index d266b94f..fae74a5b 100644 --- a/crates/engine/src/commands/expr_tools.rs +++ b/crates/engine/src/commands/expr_tools.rs @@ -43,8 +43,18 @@ impl ExprError { pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec { 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 std::result::Result<(), String>>, mut keep_going: impl FnMut() -> bool) -> Option> { 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 { } }); 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 { diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 6f3d2e70..74ec861e 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,10 +43,19 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { 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::>().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 { 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 { - 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) } } diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9aecd8d6..ef26d59b 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -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 { } 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"))?), diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index f984d53f..1bd0d223 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -179,7 +179,8 @@ fn edit_into_comp(s: &mut Session, p: &Value, ripple: bool) -> Result { 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 { 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}), ) } diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 57fdb60a..06278c2c 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -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 { // 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 { 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 { "{} — 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 { + 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})) +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09bad38f..d4cf1d90 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 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 fn pixel_aspect_p(p: &Value) -> Result> { 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 { 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 { 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 { 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, label: &str) -> Result { + 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 = + 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 { let sw = &l.switches; Some(match name { @@ -857,9 +897,13 @@ fn timing(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { }; 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 { 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) -> 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 { 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 { _ => {} } 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 { "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 { "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 { "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 ), diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index ba00db38..755dd898 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -661,7 +661,24 @@ fn open_source(s: &mut Session, p: &Value) -> Result { /// 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 { @@ -845,3 +862,21 @@ pub fn specs() -> Vec { ), ] } + +#[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"); + } +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb4..99b2d642 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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> = 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 { 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 { + 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 { match v { Value::Number(n) => { @@ -463,7 +506,7 @@ pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().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, } diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 0fd840ff..28b7274e 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -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 { "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 ), diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 8219306a..c74c3bb5 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { 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). diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 46804c87..64c166fd 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -230,8 +230,20 @@ fn json_numbers(v: &Value) -> Option> { } } +/// 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 { let twins = selected_twins(s, p, "prop.set")?; @@ -244,16 +256,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { (Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::>()), _ => None, }; + // Proportional Scrubbing: the edited layer and its twins in selection order. + let order: Option> = (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 = 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 { - 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 = 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 { "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 ), diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 2872d9a5..2ea8048f 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,6 +46,13 @@ fn summary(s: &mut Session, _: &Value) -> Result { 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 { "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::>(), })) } diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d167df9a..93a56ced 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -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 { "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 { "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 ), diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 198c9bad..3de5e6fe 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -384,6 +384,15 @@ pub fn specs() -> Vec { 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 { 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", diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 87fcd141..ecd3ed8e 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -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, 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, + /// A group with a path and no stroke gets one. + add_stroke: bool, + fill_kind: Option, + stroke_kind: Option, +} + +/// 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> { + 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 { let c = "shape.fillStroke"; let (cid, lids) = layers_p(s, p)?; @@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { 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 { 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 { })?; } 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 { @@ -303,7 +460,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index 83ab0f01..a48c32b7 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -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 { - 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 { diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index b868828d..cef0abd0 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -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 } }; } diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 1496af84..228d4842 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -115,6 +115,13 @@ fn add_animator(s: &mut Session, p: &Value) -> Result { 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 { } /// JSON → property value shaped like `like`. -fn json_value(v: &Value, like: &KV) -> Option { +pub(super) fn json_value(v: &Value, like: &KV) -> Option { Some(match v { Value::Bool(b) => match like { KV::Bool(_) => KV::Bool(*b), @@ -431,7 +438,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 0ef2d6cc..5dcb6d09 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result { 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 = 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 { let list: Vec = 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 { "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 ), diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index f9e2dd5e..e143690d 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -320,6 +320,21 @@ fn set_3d_view(s: &mut Session, p: &Value) -> Result { 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 { + 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 { @@ -706,6 +721,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index f98ea5ed..1bc4638d 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -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 { 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 { 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"}))), diff --git a/crates/engine/src/commands/view.rs b/crates/engine/src/commands/view.rs index 4cb001f6..abf92416 100644 --- a/crates/engine/src/commands/view.rs +++ b/crates/engine/src/commands/view.rs @@ -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 { 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 { + 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 { + 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 { 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 { 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 { 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), diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index f5db284e..423fd69f 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -210,7 +210,13 @@ mod tests { let mut paths: Vec = 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); diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 6d905a83..8b178a05 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -341,7 +341,8 @@ impl Session { let k = new_line.iter().position(|&i| i == target).unwrap_or(0); let undo: Vec<(String, Arc)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect(); let redo: Vec<(String, Arc)> = 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; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606..a6243e44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + }, } 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 = 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) { 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 { 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> { } /// 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 { 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 = 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 diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index f5750372..2c89466a 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -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..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, LevelFilter)>> = OnceLock::new(); +/// The filter for standard error and the session log: (target prefix or every target, level). +static FILTER: OnceLock, LevelFilter)>> = OnceLock::new(); static STATE: Mutex = Mutex::new(State { file: None, recent: VecDeque::new() }); +static SESSION: Mutex = Mutex::new(Sink::new(MAX_FILE_BYTES)); struct State { file: Option, @@ -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): ` [] : `. +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` → … → `..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 { + 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 { + 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, + path: Option, + pending: Vec, + 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 { STATE.lock().ok().and_then(|s| s.file.clone()) } @@ -148,7 +314,8 @@ pub fn recent() -> Vec { #[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" + ); + } } diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 3247a588..5edd67ca 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -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> { diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index e98e29f6..bd1cffb0 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -322,6 +322,9 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { } "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"]} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8f6af9c2..3abf336b 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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 = 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, D::Error> { + let v = Vec::::deserialize(d)?; + Ok(v.into_iter().filter_map(|x| serde_json::from_value(x).ok()).collect()) +} + fn default_labels() -> Vec { 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, /// 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 { 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), diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index efd63a9d..b025a7a5 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -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 { diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 666a56cc..aae05a38 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -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, diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index fe376668..88c7a6e8 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -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::>(), - [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); } diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 88450c99..6d218a7b 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -110,6 +110,10 @@ pub struct Widget { pub read_only: bool, /// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…). pub handlers: Vec, + /// 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, /// 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; +/// 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, + /// The scripting engine's scheduled-task runner (set with [`Session::script`]). + #[serde(skip)] + pub tasks: Option, + /// 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, } 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 { + 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) { for w in &mut windows { @@ -687,6 +722,48 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { 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 { + 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 { + 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 { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index cd628b9f..942184e3 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { 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 { 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(), diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs index 0debec95..de39974d 100644 --- a/crates/engine/src/sysinfo.rs +++ b/crates/engine/src/sysinfo.rs @@ -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::*; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a8..72133c5d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); + } +} diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 955a8f16..cf9773f9 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -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)); +} diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests_anim_presets.rs new file mode 100644 index 00000000..4dd2576f --- /dev/null +++ b/crates/engine/src/tests_anim_presets.rs @@ -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 = 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()); +} diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index 3f319b83..87b0864a 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -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"))); +} diff --git a/crates/engine/src/tests_fuzz_open.rs b/crates/engine/src/tests_fuzz_open.rs new file mode 100644 index 00000000..5ccd84cf --- /dev/null +++ b/crates/engine/src/tests_fuzz_open.rs @@ -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); + +impl Services for OneFile { + fn read_file(&self, path: &str) -> std::io::Result> { + 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 { + 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) -> Result { + 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| 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::>())).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, deadline: Duration) -> Result { + 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::().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> { + 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", "<", ">", "", "", "", "", + "", "", "", "", "", "", "", "", "&", ";", "�", + "�", "&#;", "&#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) { + 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) = [("[", "]"), ("", ""), ("", "")][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 = 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]) -> Vec { + 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::(&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()); +} diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs new file mode 100644 index 00000000..68f2c04c --- /dev/null +++ b/crates/engine/src/tests_limits.rs @@ -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)); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c7252acb..cb83c163 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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!(), + } +} diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests_model3d.rs index d142e5f7..40a16579 100644 --- a/crates/engine/src/tests_model3d.rs +++ b/crates/engine/src/tests_model3d.rs @@ -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(); diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b77c7e9b..b442d7c3 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -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> { + None + } + fn problem(&self, f: &Footage) -> Option { + 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"]); +} diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index a89485f9..7f937596 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -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}"); +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 1a445205..02c2a00b 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -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::>(), ["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::>(), ["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 { + None + } + fn supports_effect(&self, _: &str) -> bool { + false + } + fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option) -> Option { + 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}"); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 46d5d17f..edf77355 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -270,18 +270,26 @@ fn rq() -> (Session, Arc) { (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::>(), - [("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(); diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 5794e477..e409fc66 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -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}"); +} diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index ef87bf86..3e41688d 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -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 { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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 = 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] diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b..009af9e5 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -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 { + 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 = 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 { + 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::>(), [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)); +} diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index d205630c..e33a38b0 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -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##" + + + + +"##; + 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", "", ""] { + 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"); +} diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index a6139668..be3edf46 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -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); +} diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 34edf5b7..23400953 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec, 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 diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index b9758668..5abb942a 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -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>; + +/// 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, +} + +impl ExprHost for SnapExpressions<'_> { + fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result { + 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 { + 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 { 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, } + +#[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 { + 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); + } +} diff --git a/crates/engine/src/xml_project.rs b/crates/engine/src/xml_project.rs index ae899d46..34ed07ca 100644 --- a/crates/engine/src/xml_project.rs +++ b/crates/engine/src/xml_project.rs @@ -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 { + fn value(&mut self, depth: usize) -> Result { + 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(" P<'a> { if tag != "m" { return self.err("expected "); } - 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 { 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("").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}>{}{}", "".repeat(n), "".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::(&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::(&from_xml(&to_xml(&json).unwrap()).unwrap()).unwrap(), v); + } } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index f4814d6c..2ef6f5ad 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -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 diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 098a8c11..e4a45561 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -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 = 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 { job.pixels(&img, comp, channels, w, h) }; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5d8405c0..eda97995 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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)?, diff --git a/crates/export/src/uncompressed.rs b/crates/export/src/uncompressed.rs new file mode 100644 index 00000000..1bd1d9ba --- /dev/null +++ b/crates/export/src/uncompressed.rs @@ -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 { + 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 { + 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> { + 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> { + 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 = (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()); + } +} diff --git a/crates/export/tests/uncompressed_mov.rs b/crates/export/tests/uncompressed_mov.rs new file mode 100644 index 00000000..1f7a6657 --- /dev/null +++ b/crates/export/tests/uncompressed_mov.rs @@ -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> { + 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> { + 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>> { + 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 { + 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 = 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 = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect(); + let rms = |v: Vec| (v.iter().map(|x| (*x as f64).powi(2)).sum::() / 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}"); + } +} diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index add94b7c..4963810c 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -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 { - 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 => { diff --git a/crates/expr/src/noise.rs b/crates/expr/src/noise.rs index 854f15ce..0feddbbd 100644 --- a/crates/expr/src/noise.rs +++ b/crates/expr/src/noise.rs @@ -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 { diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index ca54d231..55e9800a 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -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 { + let c: Vec = 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::() + "0"; + let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::>().join(", ")); + let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::>().join(", ")); + let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::>().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]); + } + } } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index bb841e6d..142658b1 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -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, pub misses: Vec, + /// What this frame evaluates: an evaluation that needs itself is a cycle. + pub owner: Option, +} + +/// 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>>> = const { RefCell::new(None) }; /// Evaluation stack (nested when one expression reads another's value). pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; + /// Script runs since the outermost evaluation on this thread began (nested ones included). + pub static RUNS: std::cell::Cell = 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> = const { std::cell::Cell::new(None) }; } struct Runtime { @@ -209,7 +234,7 @@ impl Runtime { fn new() -> Result { 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); 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> { + 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 = 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 { 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::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(); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 133d32b5..93a25562 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -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] diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs new file mode 100644 index 00000000..b9fb752b --- /dev/null +++ b/crates/expr/src/tests/fuzz.rs @@ -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 = r.pick(SEEDS).chars().collect(); + if r.chance(4) { + // Splice two expressions together. + let other: Vec = r.pick(SEEDS).chars().collect(); + let at = r.below(s.len() + 1); + let glue: Vec = 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::().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 = 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 = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect(); + let n: Vec = 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 = 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 = 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(r: &Result) -> 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, + results: Receiver>, +} + +impl Worker { + fn start() -> Result { + let (jobs, rx) = channel::(); + 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::().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::()); + } + 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()); +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 788757bf..11f28c70 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -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); } } diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d7..42d2bab7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -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 { diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs index 564ba6fd..71f7a582 100644 --- a/crates/gpu/src/fx_time.rs +++ b/crates/gpu/src/fx_time.rs @@ -45,13 +45,18 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option>()) } - "ec.time.pixelmotionblur" => { - 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 = (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(e, ctx, b, ×) - } + // Pixel Motion Blur synthesizes along measured motion vectors (#535); without a plan (no + // host to read neighbouring frames) it averages the shutter's frames, as the CPU effect does. + "ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) { + Some(plan) => render_plan(e, &plan), + None => { + 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 = (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(e, ctx, b, ×) + } + }, "ec.time.timewarp" => timewarp(e, ctx, b), _ => None, } @@ -179,6 +184,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { return Some(b); } let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) }; + render_plan(e, &plan) +} + +/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`): +/// every step to an image on the GPU, then the weighted average. +fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option { // Pixel Motion's in-between frame of frames a, b along motion field `flow`. let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option { let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?; diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index b74607a0..a0ad5465 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -448,6 +448,13 @@ fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0][0] = pr.e("render"); p.f[0] = f4([c.0, c.1, r, h]); p.f[2][0] = pr.f("rotation").to_radians() as f32; + let (rx, ry, rz) = (pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ")); + p.u[0][1] = (rx != 0.0 || ry != 0.0 || rz != 0.0) as u32; + let m = rot_xyz(rx, ry, rz); + // Matrix rows, as for CC Sphere; the kernel uses Rᵀ for mapping. + for (i, row) in m.iter().enumerate() { + p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]); + } run(e, "ftr_cylinder", &p, b, None, None) } diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 7fd3702a..1f62d442 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -665,7 +665,7 @@ fn fxn_fractal(@builtin(global_invocation_id) gid: vec3) { r = blend_pixel(P.u[2].x, orig, s, 0.5); } let bo = P.f[1].w; - textureStore(out, p, orig * bo + r * (1.0 - bo)); + textureStore(out, p, orig * bo + r * orig.a * (1.0 - bo)); } // ---------------------------------------------------------------- Noise Alpha / Noise HLS (noise.rs) diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 66278a68..379b6446 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -666,7 +666,24 @@ fn ftr_faces(mode: u32, front: vec4, back: vec4) -> vec4 { return ftr_over(front, back); } -// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation. +// Cylinder: u[0].x = render, u[0].y = axis rotation enabled; +// f[0] = (cx, cy, radius, layer height); f[2].x = legacy texture spin; +// f[7..10] = rows of the local-to-view rotation matrix. +fn ftr_cylinder_face(d: vec2, z: f32, back: bool) -> vec4 { + let m0 = P.f[7].xyz; + let m1 = P.f[8].xyz; + let m2 = P.f[9].xyz; + let q = vec3(d, z); + let local = vec3(m0.x * q.x + m1.x * q.y + m2.x * q.z, + m0.y * q.x + m1.y * q.y + m2.y * q.z, + m0.z * q.x + m1.z * q.y + m2.z * q.z); + let u = (atan2(local.x, local.z) + P.f[2].x) / 6.283185307179586 + 0.5; + let v = local.y / P.f[0].w + 0.5; + let flip = select(1.0, -1.0, back); + let n = vec3(local.x / P.f[0].z * flip, 0.0, local.z / P.f[0].z * flip); + return ftr_shade(ftr_texel(u, v), vec3(dot(m0, n), dot(m1, n), dot(m2, n))); +} + @compute @workgroup_size(16, 16) fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { let p = vec2(gid.xy); @@ -674,18 +691,35 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { return; } let d = ftr_centre(p) - P.f[0].xy; - let v = d.y / P.f[0].w + 0.5; - let s = d.x / P.f[0].z; - if (abs(s) >= 1.0) { + if (P.u[0].y == 0u) { + // Original path is unchanged for old projects and default parameters. + let v = d.y / P.f[0].w + 0.5; + let s = d.x / P.f[0].z; + if (abs(s) >= 1.0) { + textureStore(out, p, vec4(0.0)); + return; + } + let tau = 6.283185307179586; + let th = asin_p(s); + let cz = cos(th); + let spin = P.f[2].x; + let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3(s, 0.0, cz)); + let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + textureStore(out, p, ftr_faces(P.u[0].x, front, back)); + return; + } + let axis = vec3(P.f[7].y, P.f[8].y, P.f[9].y); + let a = 1.0 - axis.z * axis.z; + let da = dot(d, axis.xy); + let bq = -da * axis.z; + let disc = bq * bq - a * (dot(d, d) - da * da - P.f[0].z * P.f[0].z); + if (a <= 1e-10 || !(disc > 0.0)) { textureStore(out, p, vec4(0.0)); return; } - let tau = 6.283185307179586; - let th = asin_p(s); - let cz = cos(th); - let spin = P.f[2].x; - let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3(s, 0.0, cz)); - let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + let root = sqrt(disc); + let front = ftr_cylinder_face(d, (-bq + root) / a, false); + let back = ftr_cylinder_face(d, (-bq - root) / a, true); textureStore(out, p, ftr_faces(P.u[0].x, front, back)); } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 1e1ad167..406472e7 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].y == 1u) { px = vec4(max((c + br - 0.5) * k + 0.5, vec3(0.0)) * a, a); } else if (P.u[0].y == 2u) { - // Brightness & Contrast tone curves (effects crate bc_modern). + // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let pb = exp2(-br); - let g = exp2(2.0 * ct); + let g = max(1.0 + 0.53 * ct, 0.05); for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { - let v = powz(max(c[i], 0.0), pb); - if (v < 0.5) { - o[i] = 0.5 * powz(2.0 * v, g); - } else { - o[i] = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g); + var v = max(c[i], 0.0); + if (br > 0.0) { + v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); + } else if (br < 0.0) { + v = powz(v, exp2(-1.32 * br)); } + if (ct != 0.0) { + if (v < 0.5) { + v = 0.5 * powz(2.0 * v, g); + } else { + v = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g); + } + } + o[i] = v; } } px = vec4(o * a, a); @@ -858,13 +865,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { val = 1.0 - val; } let op = P.f[2].z; + var fx = vec4(vec3(val) * op, op); if (P.u[1].z == 1u) { - // Normal: the noise over the layer at Opacity, then Blend With Original. - let over = vec4(vec3(val) * op, op) + px * (1.0 - op); - px = px * P.f[2].y + over * (1.0 - P.f[2].y); - } else { - px = put(px, vec3(val), op, P.f[2].y); + // Normal: the noise over the layer at Opacity. + fx = fx + px * (1.0 - op); } + // Inside the layer's alpha (its masks), then Blend With Original. + px = px * P.f[2].y + fx * px.w * (1.0 - P.f[2].y); } default: {} } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 150f29ad..784a393d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -839,3 +839,21 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { check(&format!("missing kernels {depth:?}"), Some(d), 0.0); } } + +/// A layer whose bounds change every frame reads back at a new size each time: the staging +/// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). +#[test] +fn readback_staging_stays_bounded_across_sizes() { + let Some(g) = gpu() else { return }; + if !g.ctx.can_wait() { + return; + } + let mut e = crate::context::Enc::new(&g.ctx); + // 1024 × (256 + i) RGBA f32: ~4 MiB each, ~480 MiB over all sizes. + for i in 0..120 { + let img = g.ctx.image(1024, 256 + i); + assert!(e.download(&img).is_some(), "readback {i}"); + } + let held = g.ctx.staging_bytes(); + assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes"); +} diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs index ad6ebdf5..b4b3572a 100644 --- a/crates/gpu/src/tests_fx_transition.rs +++ b/crates/gpu/src/tests_fx_transition.rs @@ -89,6 +89,11 @@ fn perspective() { effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]); effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]); effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]); + // All three axes: independently and combined, against the CPU reference. + effect_case("ec.perspective.cccylinder", &[("rotationX", n(35.0)), ("render", e(1))]); + effect_case("ec.perspective.cccylinder", &[("rotationY", n(65.0)), ("rotation", n(15.0))]); + effect_case("ec.perspective.cccylinder", &[("rotationZ", n(-25.0)), ("render", e(2))]); + effect_case("ec.perspective.cccylinder", &[("rotationX", n(30.0)), ("rotationY", n(20.0)), ("rotationZ", n(15.0))]); effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]); effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]); effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]); diff --git a/crates/host/tests/gif_animates.rs b/crates/host/tests/gif_animates.rs new file mode 100644 index 00000000..3cf6a8c3 --- /dev/null +++ b/crates/host/tests/gif_animates.rs @@ -0,0 +1,58 @@ +//! Animated GIF footage through the wired session (#651): the imported item is a movie whose +//! frames follow the file's own delays in preview rendering, not one frozen still. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{ItemId, ItemKind}; +use effectcraft_time::Tick; +use serde_json::json; + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("effectcraft-host-gif-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name).to_string_lossy().to_string() +} + +/// A 3-frame 16×16 GIF: red, green, blue, one tenth of a second each. +fn animated_gif() -> Vec { + let mut out = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut out, 16, 16, &[]).unwrap(); + for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] { + let mut f = gif::Frame::from_rgb(16, 16, &rgb.repeat(256)); + f.delay = 10; + enc.write_frame(&f).unwrap(); + } + } + out +} + +fn centre(s: &effectcraft_engine::Session, comp: ItemId, t: f64) -> [f32; 3] { + let img = s.render(comp, Tick::from_seconds_f64(t), RenderOpts::default()); + let p = img.data[8 * 16 + 8]; + [p[0], p[1], p[2]] +} + +#[test] +fn an_imported_animated_gif_plays_its_frames() { + let mut s = effectcraft_host::session(); + let path = tmp("anim.gif"); + std::fs::write(&path, animated_gif()).unwrap(); + s.execute("comp.new", json!({"name": "Gif", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap(); + let before = s.project.items.len(); + s.execute("file.import", json!({"paths": [path.clone()], "addToComp": true})).unwrap(); + let f = match s.project.items.values().find_map(|i| match &i.kind { + ItemKind::Footage(f) if f.path == path => Some(f), + _ => None, + }) { + Some(f) => f.clone(), + None => panic!("no footage item for the GIF"), + }; + assert_eq!(s.project.items.len(), before + 1, "one footage item, not a comp"); + assert_eq!((f.codec.as_str(), f.has_video), ("GIF", true), "probed as an animated movie"); + let cid = s.active_comp_id().unwrap(); + let (r, g, b) = (centre(&s, cid, 0.05), centre(&s, cid, 0.15), centre(&s, cid, 0.25)); + let _ = std::fs::remove_file(&path); + assert!(r[0] > 0.9 && r[1] < 0.1, "frame at 0.05 s is red, got {r:?}"); + assert!(g[1] > 0.9 && g[0] < 0.1, "frame at 0.15 s is green, got {g:?}"); + assert!(b[2] > 0.9 && b[0] < 0.1, "frame at 0.25 s is blue, got {b:?}"); +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 787a0643..e0ee137b 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -287,7 +287,9 @@ impl SpatialSeg { /// along curved paths instead of piecewise linear per cell (#456). fn at_length(&self, s: f64) -> [f64; 3] { let len = self.length(); - if len <= 0.0 { + // A path whose length overflowed (huge coordinates) or is NaN has no arc-length table to + // walk: `clamp` would panic on a NaN bound. + if !(len > 0.0 && len.is_finite()) { return self.p[0]; } let s = s.clamp(0.0, len); @@ -751,6 +753,38 @@ mod tests { assert_eq!(g.coerce_json(&serde_json::json!(5)), None); } + /// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies + /// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%). + #[test] + fn gradient_tracks_sample_separately() { + let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] }; + let c = g.color_at(0.5); + assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}"); + assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}"); + assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6); + assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once"); + // A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%. + let mut with_stop = g.clone(); + with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5))); + assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6); + assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look"); + // Outside the stops, and with none: the ends hold, and an empty track is opaque white. + assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]); + assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]); + let empty = Gradient { colors: vec![], opacities: vec![] }; + assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0)); + } + + /// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser + /// rejects) and each stop's RGBA come back exactly. + #[test] + fn typed_gradient_round_trips_one_stop_and_rgba() { + let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] }); + let cur = Value::Gradient(Gradient::default()); + assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone())); + assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours"); + } + #[test] fn linear_and_hold() { let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))]; @@ -846,6 +880,30 @@ mod tests { assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); } + /// #880: a motion path whose arc length overflows (huge coordinates, as the Position field + /// accepts) or is NaN evaluates without panicking: scrubbing over it crashed the app. + #[test] + fn overflowing_or_nan_motion_paths_evaluate() { + let paths = [ + [[-1.7e308, 0.0], [1.7e308, 0.0], [-1.7e308, 5.0]], + [[0.0, 0.0], [f64::NAN, 10.0], [20.0, 0.0]], + [[f64::INFINITY, 0.0], [0.0, f64::NEG_INFINITY], [20.0, 0.0]], + ]; + for path in paths { + let mut keys: Vec = path.iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64 * 2.0), Value::Vec2(v))).collect(); + for eased in [false, true] { + if eased { + keys = keys.into_iter().map(Keyframe::eased).collect(); + } + for f in 0..=40 { + let t = s(f as f64 * 0.1); + assert!(evaluate(&keys, t, true).is_some()); + let _ = speed(&keys, t, true); + } + } + } + } + #[test] fn spatial_auto_bezier_tangents_follow_after_effects() { // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index fc921505..fa66711b 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -169,6 +169,24 @@ impl Gradient { let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0); [col[0], col[1], col[2], col[3] * a] } + + /// The colour stops alone at `t` (0..1), RGBA as stored: unlike [`Gradient::sample`], the + /// opacity stops are not applied. A new colour stop takes this value. + pub fn color_at(&self, t: f64) -> [f32; 4] { + sample_stops(&self.colors, t.clamp(0.0, 1.0), |a, b, f| { + let mut o = [0.0; 4]; + for i in 0..4 { + o[i] = a[i] + (b[i] - a[i]) * f as f32; + } + o + }) + .unwrap_or([1.0; 4]) + } + + /// The opacity stops alone at `t` (0..1). A new opacity stop takes this value. + pub fn opacity_at(&self, t: f64) -> f32 { + sample_stops(&self.opacities, t.clamp(0.0, 1.0), |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0) + } } fn sample_stops(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option { diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0d7803fd..a3ff624b 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -20,12 +20,13 @@ effectcraft-pdf = { workspace = true } effectcraft-model = { workspace = true } filmcraft-codecs = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } exr = { workspace = true } gif = { workspace = true } -image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } +image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } rayon = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 7865c3dc..d483e4b4 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -8,6 +8,7 @@ use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::FloatCurve; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use rayon::prelude::*; @@ -17,24 +18,33 @@ use rayon::prelude::*; pub(crate) struct AlphaOp { mode: AlphaMode, matte: [f32; 3], - /// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not - /// sRGB-encoded). - preserve_rgb: bool, + /// How float values (linear light) are encoded: sRGB by default, a pure 2.4 power curve + /// (Working Gamma 2.4), or not at all (Preserve RGB, Interpret As Linear Light). + float: FloatCurve, } impl AlphaOp { pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self { - Self { mode, matte, preserve_rgb: false } + Self { mode, matte, float: FloatCurve::Srgb } } - /// The interpretation of footage `f`. - pub(crate) fn of(f: &Footage) -> Self { - Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) } + /// The interpretation of footage `f`, float values handed over as `float` says (as stored + /// under Preserve RGB, whatever it says). + pub(crate) fn of(f: &Footage, float: FloatCurve) -> Self { + let float = if f.preserve_rgb { FloatCurve::Linear } else { float }; + Self { float, ..Self::new(f.alpha, f.premul_color) } } - /// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value - /// under Preserve RGB. + /// How float values are encoded. + pub(crate) fn float_curve(self) -> FloatCurve { + self.float + } + /// A linear float value as the compositor takes it. #[inline(always)] fn float(self, v: f32) -> f32 { - if self.preserve_rgb { v } else { encode(v) } + match self.float { + FloatCurve::Srgb => encode(v), + FloatCurve::Gamma24 => v.abs().powf(1.0 / 2.4).copysign(v), + FloatCurve::Linear => v, + } } /// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha. #[inline(always)] @@ -104,6 +114,32 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - img } +/// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]: +/// clockwise quarter turns; anything else leaves it as decoded). +pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image { + use image::metadata::Orientation; + let (w, h) = (img.width as usize, img.height as usize); + if img.data.len() != w * h || w == 0 || h == 0 { + return img; + } + // The source pixel shown at (x, y) of the turned picture. + let (tw, th, from): (usize, usize, fn(usize, usize, usize, usize) -> usize) = match turn { + Orientation::Rotate90 => (h, w, |x, y, w, h| (h - 1 - x) * w + y), + Orientation::Rotate180 => (w, h, |x, y, w, h| (h - 1 - y) * w + (w - 1 - x)), + Orientation::Rotate270 => (h, w, |x, y, w, _| x * w + (w - 1 - y)), + _ => return img, + }; + let mut data = vec![[0.0; 4]; tw * th]; + data.par_chunks_mut(tw).enumerate().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if let Some(s) = img.data.get(from(x, y, w, h)) { + *px = *s; + } + } + }); + Image { width: tw as u32, height: th as u32, data } +} + /// A FilmCraft frame for a render that shows it at `w` × `h`, at most half its size each way: /// what `resample(&frame_to_image(f, op), w, h)` makes, converting a quarter of the pixels. /// Opaque 4:2:0 Y'CbCr is box-filtered to half size before conversion (each 2×2 block of luma, diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs new file mode 100644 index 00000000..2ef417dd --- /dev/null +++ b/crates/media/src/downmix.rs @@ -0,0 +1,133 @@ +//! Stereo downmix of multichannel audio, shared by preview, waveform and export mixdown (they +//! all read footage audio through `MediaPool::audio_samples`). +//! +//! The matrix is the usual ITU-R BS.775 one: front L/R at unity, the centre and every surround +//! or back channel at −3 dB (1/√2) into both sides' own side (centre into both), the low +//! frequency effects channel left out (as ffmpeg's and swresample's default, `lfe_mix_level` 0; +//! not verified against Adobe's documentation, which does not state it). A back-centre channel +//! reaches both sides at −6 dB. The whole matrix is then scaled by one fixed gain, the reciprocal +//! of the largest row sum (ffmpeg's default normalisation), so that full-scale input can never +//! exceed full scale: 5.1 is scaled by 1/(1 + 0.707 + 0.707). Mono stays duplicated at unity and +//! stereo is untouched. +//! +//! Channel order follows the WAVE / SMPTE convention by channel count (what FilmCraft's AC-3, PCM, +//! FLAC and Opus readers deliver): 3 = L R C, 4 = quad L R Ls Rs, 5 = L R C Ls Rs, +//! 6 = L R C LFE Ls Rs, 7 = L R C LFE Cs Ls Rs, 8 = L R C LFE Lb Rb Ls Rs. FilmCraft's AAC-LC +//! decoder instead delivers AAC's own order (C L R first, LFE last), so AAC gets its own tables. +//! More than 8 channels carry no known layout: only the first two (front L/R) are used. + +use std::f32::consts::FRAC_1_SQRT_2; + +/// A source channel's contribution to the left and right outputs, before normalisation. +type Pair = [f32; 2]; + +const FL: Pair = [1.0, 0.0]; +const FR: Pair = [0.0, 1.0]; +const FC: Pair = [FRAC_1_SQRT_2, FRAC_1_SQRT_2]; +const LFE: Pair = [0.0, 0.0]; +const SL: Pair = [FRAC_1_SQRT_2, 0.0]; +const SR: Pair = [0.0, FRAC_1_SQRT_2]; +const CS: Pair = [0.5, 0.5]; + +/// The per-channel `[left, right]` gains for `count` channels in the given order, normalised. +/// `aac` selects AAC's native channel order. Empty for zero channels. +pub fn stereo_gains(count: usize, aac: bool) -> Vec { + let raw: &[Pair] = match (count, aac) { + (0, _) => &[], + (1, _) => &[[1.0, 1.0]], + (2, _) => &[FL, FR], + (3, false) => &[FL, FR, FC], + (3, true) => &[FC, FL, FR], + (4, false) => &[FL, FR, SL, SR], + (4, true) => &[FC, FL, FR, CS], + (5, false) => &[FL, FR, FC, SL, SR], + (5, true) => &[FC, FL, FR, SL, SR], + (6, false) => &[FL, FR, FC, LFE, SL, SR], + (6, true) => &[FC, FL, FR, SL, SR, LFE], + (7, false) => &[FL, FR, FC, LFE, CS, SL, SR], + (7, true) => &[FC, FL, FR, SL, SR, CS, LFE], + (8, false) => &[FL, FR, FC, LFE, SL, SR, SL, SR], + (8, true) => &[FC, FL, FR, SL, SR, SL, SR, LFE], + _ => &[FL, FR], + }; + let mut out = raw.to_vec(); + // Channels beyond a known layout (more than 8) are silent. + out.resize(count, [0.0, 0.0]); + let peak = out.iter().map(|g| g[0]).sum::().max(out.iter().map(|g| g[1]).sum::()); + // Mono is deliberately left at unity on both sides; so is anything already within range. + if count > 2 && peak > 1.0 { + for g in &mut out { + g[0] /= peak; + g[1] /= peak; + } + } + out +} + +/// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a +/// channel is too short for count as silence, extra frames in `out` stay as they are. +pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { + let gains = stereo_gains(channels.len(), aac); + for (ch, g) in channels.iter().zip(&gains) { + if g[0] == 0.0 && g[1] == 0.0 { + continue; + } + for (o, v) in out.as_chunks_mut::<2>().0.iter_mut().zip(ch) { + o[0] += v * g[0]; + o[1] += v * g[1]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_channel_count_has_a_bounded_defined_matrix() { + for aac in [false, true] { + for n in 0..40usize { + let g = stereo_gains(n, aac); + assert_eq!(g.len(), n); + let (l, r) = (g.iter().map(|p| p[0]).sum::(), g.iter().map(|p| p[1]).sum::()); + if n > 2 { + assert!(l <= 1.0001 && r <= 1.0001, "{n} channels (aac {aac}): {l} {r}"); + assert!(l > 0.0 && r > 0.0); + } + } + } + } + + #[test] + fn five_one_matches_the_bs775_matrix() { + let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); + let g = stereo_gains(6, false); + assert!((g[0][0] - k).abs() < 1e-6 && g[0][1] == 0.0); + assert!((g[2][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && (g[2][1] - k * FRAC_1_SQRT_2).abs() < 1e-6); + assert_eq!(g[3], [0.0, 0.0], "LFE is left out"); + assert!((g[4][0] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[4][1] == 0.0); + assert!((g[5][1] - k * FRAC_1_SQRT_2).abs() < 1e-6 && g[5][0] == 0.0); + // AAC order: the centre is first, LFE last + let a = stereo_gains(6, true); + assert_eq!((a[0], a[5]), (g[2], g[3])); + assert_eq!((a[1], a[2]), (g[0], g[1])); + } + + #[test] + fn mono_and_stereo_are_untouched_and_wide_layouts_use_the_front_pair() { + assert_eq!(stereo_gains(1, false), vec![[1.0, 1.0]]); + assert_eq!(stereo_gains(2, true), vec![FL, FR]); + let g = stereo_gains(12, false); + assert_eq!((g[0], g[1], g[2]), (FL, FR, [0.0, 0.0])); + } + + #[test] + fn ragged_and_empty_input_never_panics() { + let mut out = vec![0.0; 8]; + downmix_into(&[], false, &mut out); + downmix_into(&[vec![1.0; 100], vec![1.0; 2], vec![], vec![1.0; 3], vec![1.0; 1], vec![1.0]], true, &mut out); + assert!(out.iter().all(|v| v.is_finite())); + let mut none: [f32; 0] = []; + downmix_into(&vec![vec![1.0; 3]; 7], false, &mut none); + } +} diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 00000000..a5b2abda --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,481 @@ +//! DPX (SMPTE 268M) and Kodak Cineon stills, the film-scan interchange formats, read from the +//! public specifications. +//! +//! Supported: luma, RGB, RGBA and ABGR (DPX) or one / three channels (Cineon), 8-, 10-, 12- and +//! 16-bit unsigned samples, 10-bit filled into 32-bit words with the pad bits low (DPX method A, +//! Cineon "32-bit left justified") or high (method B, "right justified"), 12-bit in 16-bit words, +//! big- and little-endian files, the four unrotated image orientations and the pixel aspect ratio. +//! Code values are shown as stored (a log scan looks flat, as in After Effects without a Cineon +//! conversion); the codec names the transfer (`log`, `linear`, …). Anything else (run-length +//! encoding, signed or float samples, Y'CbCr, packed 10/12-bit, transposed images) is refused +//! with an error naming it. Every field is read with bounds checks. + +use effectcraft_project::{AlphaMode, Footage}; +use rayon::prelude::*; + +use crate::{MediaError, Result}; + +/// File extensions of DPX and Cineon files. +pub(crate) const EXTENSIONS: &[&str] = &["dpx", "cin"]; + +/// Largest image accepted (8192 × 8192): bigger headers are refused before allocating. +const MAX_PIXELS: u64 = 1 << 26; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Kind { + Dpx, + Cineon, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Layout { + Luma, + Rgb, + Rgba, + Abgr, +} + +impl Layout { + fn components(self) -> usize { + match self { + Layout::Luma => 1, + Layout::Rgb => 3, + Layout::Rgba | Layout::Abgr => 4, + } + } + fn name(self) -> &'static str { + match self { + Layout::Luma => "luma", + Layout::Rgb => "RGB", + Layout::Rgba => "RGBA", + Layout::Abgr => "ABGR", + } + } +} + +/// How samples are stored in a line. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Packing { + /// One byte per sample. + Bytes8, + /// One 16-bit word per sample. + Words16, + /// Three 10-bit samples per 32-bit word, first in the high bits, two pad bits low. + Filled10A, + /// Three 10-bit samples per 32-bit word, two pad bits high. + Filled10B, + /// One 12-bit sample per 16-bit word, four pad bits low. + Filled12A, + /// One 12-bit sample per 16-bit word, four pad bits high. + Filled12B, +} + +impl Packing { + fn max(self) -> u32 { + match self { + Packing::Bytes8 => 0xff, + Packing::Words16 => 0xffff, + Packing::Filled10A | Packing::Filled10B => 0x3ff, + Packing::Filled12A | Packing::Filled12B => 0xfff, + } + } + fn bits(self) -> u32 { + match self { + Packing::Bytes8 => 8, + Packing::Words16 => 16, + Packing::Filled10A | Packing::Filled10B => 10, + Packing::Filled12A | Packing::Filled12B => 12, + } + } + /// Bytes holding `n` samples, padded to a 32-bit word (lines start on word boundaries). + fn line_bytes(self, n: u64) -> u64 { + match self { + Packing::Bytes8 => n.div_ceil(4) * 4, + Packing::Words16 | Packing::Filled12A | Packing::Filled12B => (n * 2).div_ceil(4) * 4, + Packing::Filled10A | Packing::Filled10B => n.div_ceil(3) * 4, + } + } +} + +/// What a header says about the picture. +#[derive(Clone, Debug)] +struct Header { + kind: Kind, + big_endian: bool, + width: u32, + height: u32, + layout: Layout, + packing: Packing, + /// Byte offset of the first line. + offset: u64, + /// Bytes from one line's start to the next. + stride: u64, + flip_x: bool, + flip_y: bool, + pixel_aspect: f64, + /// Transfer characteristic for the codec name (`log`, `linear`, …), empty when unstated. + transfer: &'static str, +} + +/// Bounds-checked reads in the file's byte order. +struct Reader<'a> { + b: &'a [u8], + big: bool, +} + +impl Reader<'_> { + fn u8(&self, o: usize) -> Option { + self.b.get(o).copied() + } + fn u16(&self, o: usize) -> Option { + let s: [u8; 2] = self.b.get(o..o.checked_add(2)?)?.try_into().ok()?; + Some(if self.big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + fn u32(&self, o: usize) -> Option { + let s: [u8; 4] = self.b.get(o..o.checked_add(4)?)?.try_into().ok()?; + Some(if self.big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } +} + +const UNDEFINED32: u32 = u32::MAX; + +/// DPX or Cineon, and the byte order, from the magic number. +fn sniff(bytes: &[u8]) -> Option<(Kind, bool)> { + match bytes.get(..4)? { + b"SDPX" => Some((Kind::Dpx, true)), + b"XPDS" => Some((Kind::Dpx, false)), + [0x80, 0x2a, 0x5f, 0xd7] => Some((Kind::Cineon, true)), + [0xd7, 0x5f, 0x2a, 0x80] => Some((Kind::Cineon, false)), + _ => None, + } +} + +fn unsupported(path: &str, what: impl std::fmt::Display) -> MediaError { + MediaError::Unsupported(format!("{path}: {what}")) +} + +fn truncated(path: &str, kind: Kind) -> MediaError { + let name = if kind == Kind::Dpx { "DPX" } else { "Cineon" }; + MediaError::Decode(format!("{path}: truncated {name} header")) +} + +fn parse(path: &str, bytes: &[u8]) -> Result> { + let Some((kind, big_endian)) = sniff(bytes) else { return Ok(None) }; + let r = Reader { b: bytes, big: big_endian }; + let h = match kind { + Kind::Dpx => parse_dpx(path, &r)?, + Kind::Cineon => parse_cineon(path, &r)?, + }; + let pixels = u64::from(h.width) * u64::from(h.height); + if h.width == 0 || h.height == 0 || pixels > MAX_PIXELS { + return Err(MediaError::Decode(format!("{path}: image size {} × {} out of range", h.width, h.height))); + } + let need = h.offset.saturating_add(h.stride.saturating_mul(u64::from(h.height))); + if (bytes.len() as u64) < need { + return Err(MediaError::Decode(format!("{path}: truncated image data ({} of {need} bytes)", bytes.len()))); + } + Ok(Some(h)) +} + +fn parse_dpx(path: &str, r: &Reader) -> Result
{ + let t = || truncated(path, Kind::Dpx); + let image_offset = r.u32(4).ok_or_else(t)?; + let orientation = r.u16(768).ok_or_else(t)?; + let elements = r.u16(770).ok_or_else(t)?; + let width = r.u32(772).ok_or_else(t)?; + let height = r.u32(776).ok_or_else(t)?; + // The first image element (the picture; further elements are alternative views). + let sign = r.u32(780).ok_or_else(t)?; + let descriptor = r.u8(800).ok_or_else(t)?; + let transfer = r.u8(801).ok_or_else(t)?; + let bits = r.u8(803).ok_or_else(t)?; + let packing = r.u16(804).ok_or_else(t)?; + let encoding = r.u16(806).ok_or_else(t)?; + let data_offset = r.u32(808).ok_or_else(t)?; + let eol_padding = r.u32(812).ok_or_else(t)?; + if elements == 0 || elements == u16::MAX { + return Err(unsupported(path, "DPX file without an image element")); + } + if sign == 1 { + return Err(unsupported(path, "DPX with signed samples is not supported")); + } + if encoding == 1 { + return Err(unsupported(path, "run-length encoded DPX is not supported")); + } + let layout = match descriptor { + 6 => Layout::Luma, + 50 => Layout::Rgb, + 51 => Layout::Rgba, + 52 => Layout::Abgr, + 100..=103 => return Err(unsupported(path, "DPX with Y'CbCr samples is not supported")), + d => return Err(unsupported(path, format!("DPX image element descriptor {d} is not supported (luma, RGB, RGBA and ABGR are)"))), + }; + let packing = match (bits, packing) { + (8, _) => Packing::Bytes8, + (16, _) => Packing::Words16, + (10, 1) => Packing::Filled10A, + (10, 2) => Packing::Filled10B, + (12, 1) => Packing::Filled12A, + (12, 2) => Packing::Filled12B, + (10 | 12, _) => return Err(unsupported(path, format!("DPX with packed {bits}-bit samples (packing {packing}) is not supported"))), + (b, _) => return Err(unsupported(path, format!("DPX with {b}-bit samples is not supported (8, 10, 12 and 16 are)"))), + }; + let (flip_x, flip_y) = match orientation { + 0 | u16::MAX => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(path, format!("DPX orientation {o} (transposed image) is not supported"))), + }; + let offset = if data_offset == 0 || data_offset == UNDEFINED32 { image_offset } else { data_offset }; + let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; + let samples = u64::from(width) * layout.components() as u64; + // Pixel aspect ratio (orientation header), when stated. + let pixel_aspect = match (r.u32(1628), r.u32(1632)) { + (Some(h), Some(v)) if h != 0 && v != 0 && h != UNDEFINED32 && v != UNDEFINED32 => { + let a = f64::from(h) / f64::from(v); + if (0.1..=10.0).contains(&a) { a } else { 1.0 } + } + _ => 1.0, + }; + let transfer = match transfer { + 1 => "log (printing density)", + 2 => "linear", + 3 => "log", + 5 | 6 => "Rec. 709", + 13 => "ADX", + _ => "", + }; + Ok(Header { + kind: Kind::Dpx, + big_endian: r.big, + width, + height, + layout, + packing, + offset: u64::from(offset), + stride: packing.line_bytes(samples) + u64::from(eol), + flip_x, + flip_y, + pixel_aspect, + transfer, + }) +} + +fn parse_cineon(path: &str, r: &Reader) -> Result
{ + let t = || truncated(path, Kind::Cineon); + let offset = r.u32(4).ok_or_else(t)?; + let orientation = r.u8(192).ok_or_else(t)?; + let channels = r.u8(193).ok_or_else(t)?; + // Channel 0's description; every channel must match it. + let bits = r.u8(198).ok_or_else(t)?; + let width = r.u32(200).ok_or_else(t)?; + let height = r.u32(204).ok_or_else(t)?; + let interleave = r.u8(680).ok_or_else(t)?; + let packing = r.u8(681).ok_or_else(t)?; + let signed = r.u8(682).ok_or_else(t)?; + let eol_padding = r.u32(684).ok_or_else(t)?; + let layout = match channels { + 1 => Layout::Luma, + 3 => Layout::Rgb, + c => return Err(unsupported(path, format!("Cineon with {c} channels is not supported (1 and 3 are)"))), + }; + for c in 1..usize::from(channels) { + let base = 196 + 28 * c; + if r.u8(base + 2) != Some(bits) || r.u32(base + 4) != Some(width) || r.u32(base + 8) != Some(height) { + return Err(unsupported(path, "Cineon channels of different sizes or depths are not supported")); + } + } + if interleave != 0 { + return Err(unsupported(path, "Cineon with line or channel interleave is not supported")); + } + if signed == 1 { + return Err(unsupported(path, "Cineon with signed samples is not supported")); + } + let packing = match (bits, packing) { + (8, _) => Packing::Bytes8, + (16, _) => Packing::Words16, + (10, 5) => Packing::Filled10A, + (10, 6) => Packing::Filled10B, + (10, p) => return Err(unsupported(path, format!("Cineon with 10-bit packing {p} is not supported (32-bit words are)"))), + (b, _) => return Err(unsupported(path, format!("Cineon with {b}-bit samples is not supported (8, 10 and 16 are)"))), + }; + let (flip_x, flip_y) = match orientation { + 0 | u8::MAX => (false, false), + 1 => (false, true), + 2 => (true, false), + 3 => (true, true), + o => return Err(unsupported(path, format!("Cineon orientation {o} (transposed image) is not supported"))), + }; + let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; + let samples = u64::from(width) * layout.components() as u64; + Ok(Header { + kind: Kind::Cineon, + big_endian: r.big, + width, + height, + layout, + packing, + offset: u64::from(offset), + stride: packing.line_bytes(samples) + u64::from(eol), + flip_x, + flip_y, + pixel_aspect: 1.0, + transfer: "log", + }) +} + +/// Footage for a DPX or Cineon file (`None` for other formats). +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { + let Some(h) = parse(path, bytes)? else { return Ok(None) }; + let alpha = matches!(h.layout, Layout::Rgba | Layout::Abgr); + let mut f = crate::probe::still_footage(path, h.width, h.height, image::ImageFormat::Png, alpha); + f.alpha = if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }; + f.pixel_aspect = h.pixel_aspect; + let name = if h.kind == Kind::Dpx { "DPX" } else { "Cineon" }; + f.codec = format!("{name} {}-bit {}", h.packing.bits(), h.layout.name()); + if !h.transfer.is_empty() { + f.codec = format!("{}, {}", f.codec, h.transfer); + } + Ok(Some(f)) +} + +/// The picture of a DPX or Cineon file as 16-bit samples (`None` for other formats). +pub(crate) fn decode(path: &str, bytes: &[u8]) -> Result> { + let Some(h) = parse(path, bytes)? else { return Ok(None) }; + let (w, ht) = (h.width as usize, h.height as usize); + // Output components: luma, RGB or RGBA (ABGR reordered). + let nc = h.layout.components(); + let row_len = w * nc; + let mut out = vec![0u16; row_len * ht]; + let max = h.packing.max(); + out.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| { + let sy = if h.flip_y { ht - 1 - y } else { y }; + let start = h.offset.saturating_add(h.stride.saturating_mul(sy as u64)); + let line = usize::try_from(start).ok().and_then(|s| bytes.get(s..)).unwrap_or(&[]); + for x in 0..w { + let sx = if h.flip_x { w - 1 - x } else { x }; + for c in 0..nc { + // ABGR stores alpha first and colour reversed. + let sc = if h.layout == Layout::Abgr { 3 - c } else { c }; + let v = sample(line, h.packing, h.big_endian, sx * nc + sc); + if let Some(o) = row.get_mut(x * nc + c) { + *o = ((u64::from(v.min(max)) * 0xffff + u64::from(max) / 2) / u64::from(max)) as u16; + } + } + } + }); + let (w32, h32) = (h.width, h.height); + let img = match h.layout { + Layout::Luma => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageLuma16), + Layout::Rgb => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgb16), + Layout::Rgba | Layout::Abgr => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgba16), + }; + img.map(Some).ok_or_else(|| MediaError::Decode(format!("{path}: image buffer size mismatch"))) +} + +/// Sample `i` of a line (0 past its end; the header check makes that unreachable for valid +/// headers). +#[inline] +fn sample(line: &[u8], packing: Packing, big: bool, i: usize) -> u32 { + let word16 = |o: usize| -> u32 { + match line.get(o..o + 2) { + Some(&[a, b]) => u32::from(if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) }), + _ => 0, + } + }; + let word32 = |o: usize| -> u32 { + match line.get(o..o + 4) { + Some(&[a, b, c, d]) => { + if big { + u32::from_be_bytes([a, b, c, d]) + } else { + u32::from_le_bytes([a, b, c, d]) + } + } + _ => 0, + } + }; + match packing { + Packing::Bytes8 => line.get(i).copied().map_or(0, u32::from), + Packing::Words16 => word16(i * 2), + Packing::Filled12A => word16(i * 2) >> 4, + Packing::Filled12B => word16(i * 2) & 0xfff, + Packing::Filled10A => (word32(i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3ff, + Packing::Filled10B => (word32(i / 3 * 4) >> (20 - 10 * (i % 3) as u32)) & 0x3ff, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal big-endian DPX header for `w` × `h` with descriptor, bits and packing. + fn dpx_header(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16) -> Vec { + let mut b = vec![0u8; 2048]; + b[..4].copy_from_slice(b"SDPX"); + b[4..8].copy_from_slice(&2048u32.to_be_bytes()); + b[768..770].copy_from_slice(&orientation.to_be_bytes()); + b[770..772].copy_from_slice(&1u16.to_be_bytes()); + b[772..776].copy_from_slice(&w.to_be_bytes()); + b[776..780].copy_from_slice(&h.to_be_bytes()); + b[800] = descriptor; + b[801] = 3; + b[803] = bits; + b[804..806].copy_from_slice(&packing.to_be_bytes()); + b + } + + #[test] + fn ten_bit_methods_a_and_b_and_orientation() { + // 2 × 1 RGB, method A: R G B in bits 31..22, 21..12, 11..2. + let mut a = dpx_header(2, 1, 50, 10, 1, 0); + for px in [(1023u32, 512u32, 0u32), (0, 1, 1023)] { + a.extend_from_slice(&((px.0 << 22) | (px.1 << 12) | (px.2 << 2)).to_be_bytes()); + } + let img = decode("a.dpx", &a).expect("decode").expect("dpx").to_rgb16(); + assert_eq!(img.get_pixel(0, 0).0, [65535, 32800, 0]); + assert_eq!(img.get_pixel(1, 0).0, [0, 64, 65535]); + let f = probe("a.dpx", &a).expect("probe").expect("dpx"); + assert_eq!(f.codec, "DPX 10-bit RGB, log"); + // method B: pad bits high + let mut b = dpx_header(1, 1, 50, 10, 2, 1); + b.extend_from_slice(&((1023u32 << 20) | (512 << 10)).to_be_bytes()); + let img = decode("b.dpx", &b).expect("decode").expect("dpx").to_rgb16(); + assert_eq!(img.get_pixel(0, 0).0, [65535, 32800, 0]); + // orientation 2: bottom-to-top lines + let mut c = dpx_header(1, 2, 6, 8, 0, 2); + c.extend_from_slice(&[10, 0, 0, 0, 200, 0, 0, 0]); + let img = decode("c.dpx", &c).expect("decode").expect("dpx").to_luma16(); + assert_eq!((img.get_pixel(0, 0).0[0], img.get_pixel(0, 1).0[0]), (200 * 257, 10 * 257)); + } + + #[test] + fn hostile_headers_never_panic() { + assert!(parse("x", b"not a dpx").expect("other").is_none()); + let good = { + let mut a = dpx_header(4, 4, 50, 10, 1, 0); + a.extend(std::iter::repeat_n(0x5a, 64)); + a + }; + assert!(decode("g.dpx", &good).expect("ok").is_some()); + for n in [4, 8, 600, 790, 805, 2047, 2048, 2100] { + assert!(decode("t.dpx", good.get(..n).expect("cut")).is_err(), "{n}"); + } + // huge, zero, unsupported + for (w, h, d, bits, p, o) in + [(u32::MAX, u32::MAX, 50, 10, 1, 0), (0, 4, 50, 10, 1, 0), (4, 4, 102, 10, 1, 0), (4, 4, 50, 10, 0, 0), (4, 4, 50, 32, 0, 0), (4, 4, 50, 10, 1, 5)] + { + let mut b = dpx_header(w, h, d, bits, p, o); + b.extend(std::iter::repeat_n(0, 64)); + assert!(probe("h.dpx", &b).is_err() && decode("h.dpx", &b).is_err()); + } + // run-length encoding and offsets past the end are refused + let mut rle = good.clone(); + rle[806..808].copy_from_slice(&1u16.to_be_bytes()); + assert!(matches!(probe("r.dpx", &rle), Err(MediaError::Unsupported(_)))); + let mut far = good.clone(); + far[808..812].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes()); + assert!(probe("f.dpx", &far).is_err()); + } +} diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 6e90a6dd..8c4cb133 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -75,34 +75,21 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { } }; collect_meta(&img.attributes.other); - for layer in img.layer_data.iter() { + let n = w.checked_mul(h)?; + // The decoded image is consumed channel by channel, so each channel's samples are freed (or + // moved) as its plane is made instead of the whole file being held twice (#474). + for layer in img.layer_data { collect_meta(&layer.attributes.other); let prefix = layer.attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty()); - let (lw, lh) = (layer.size.0, layer.size.1); + let size = (layer.size.0, layer.size.1); let pos = layer.attributes.layer_position; - let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64); - for ch in layer.channel_data.list.iter() { + let offset = (i64::from(pos.0) - i64::from(dw.position.0), i64::from(pos.1) - i64::from(dw.position.1)); + for ch in layer.channel_data.list { let name = channel_name(prefix.as_deref(), &ch.name.to_string()); let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 }; - let mut plane = vec![fill; w * h]; - let vals: Vec = ch.sample_data.values_as_f32().collect(); - if vals.len() < lw * lh { - continue; + if let Some(plane) = place(ch.sample_data, size, offset, (w, h), n, fill) { + aux.channels.push((name, plane)); } - for y in 0..lh { - let ty = y as i64 + oy; - if ty < 0 || ty >= h as i64 { - continue; - } - for x in 0..lw { - let tx = x as i64 + ox; - if tx < 0 || tx >= w as i64 { - continue; - } - plane[ty as usize * w + tx as usize] = vals[y * lw + x]; - } - } - aux.channels.push((name, plane)); } } for (_, (name, manifest)) in crypto { @@ -114,6 +101,45 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { Some(aux) } +/// One channel's samples (`size`, at `offset` in the `display` window of `n` pixels) as a +/// display-window plane, `fill` outside its data window. A channel covering exactly the display +/// window is converted in one pass, and an f32 one is moved without a copy. `None` when the +/// channel has fewer samples than its size. +fn place(samples: exr::image::FlatSamples, size: (usize, usize), offset: (i64, i64), display: (usize, usize), n: usize, fill: f32) -> Option> { + use exr::image::FlatSamples; + let (lw, lh) = size; + if samples.len() < lw.checked_mul(lh)? { + return None; + } + if size == display && offset == (0, 0) { + return Some(match samples { + FlatSamples::F32(mut v) => { + v.truncate(n); + v + } + FlatSamples::F16(v) => v.iter().take(n).map(|s| s.to_f32()).collect(), + FlatSamples::U32(v) => v.iter().take(n).map(|s| *s as f32).collect(), + }); + } + let value = |i: usize| match &samples { + FlatSamples::F32(v) => v.get(i).copied(), + FlatSamples::F16(v) => v.get(i).map(|s| s.to_f32()), + FlatSamples::U32(v) => v.get(i).map(|s| *s as f32), + }; + let (w, h) = display; + let mut plane = vec![fill; n]; + for y in 0..lh { + let Some(ty) = usize::try_from(y as i64 + offset.1).ok().filter(|ty| *ty < h) else { continue }; + for x in 0..lw { + let Some(tx) = usize::try_from(x as i64 + offset.0).ok().filter(|tx| *tx < w) else { continue }; + if let (Some(v), Some(p)) = (value(y * lw + x), plane.get_mut(ty * w + tx)) { + *p = v; + } + } + } + Some(plane) +} + /// Names of the beauty pass, most preferred first, matched against the last part of a layer's /// name (Blender's `ViewLayer.Combined` is `Combined`): Blender's Combined pass and its /// compositor's File Output default `Image`, the beauty / rgba / rgb / color of Nuke, Arnold and @@ -262,6 +288,31 @@ mod tests { assert!(read_exr_channels(b"not an exr").is_none()); } + /// #474: a full-window f32 channel (Cryptomatte ranks, …) becomes its plane without a copy, + /// so a decode no longer holds the file's samples twice; other channels are placed in the + /// display window as before. + #[test] + fn channels_become_planes_without_an_extra_copy() { + use exr::image::FlatSamples; + let v = vec![0.25f32; 6]; + let ptr = v.as_ptr(); + let plane = place(FlatSamples::F32(v), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap(); + assert_eq!(plane.as_ptr(), ptr, "moved, not copied"); + assert_eq!(plane, [0.25; 6]); + let half = |x: f32| exr::prelude::f16::from_f32(x); + let full = place(FlatSamples::F16(vec![half(0.5); 6]), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap(); + assert_eq!(full, [0.5; 6]); + // A 2×1 data window at (1, 1) of a 3×2 display window; the rest is the fill. + let part = place(FlatSamples::F16(vec![half(1.0), half(2.0)]), (2, 1), (1, 1), (3, 2), 6, 9.0).unwrap(); + assert_eq!(part, [9.0, 9.0, 9.0, 9.0, 1.0, 2.0]); + // Partly outside the display window: clipped, never out of bounds. + let off = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, -1), (3, 2), 6, 0.0).unwrap(); + assert_eq!(off, [0.0; 6]); + let edge = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, 0), (3, 2), 6, 0.0).unwrap(); + assert_eq!(edge, [0.0, 0.0, 7.0, 0.0, 0.0, 0.0]); + assert!(place(FlatSamples::F32(vec![1.0; 5]), (3, 2), (0, 0), (3, 2), 6, 0.0).is_none(), "too few samples"); + } + /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now /// shows its colour layer and keeps every channel for EXtractoR. diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs index 30161b0b..09c0cda3 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -3,11 +3,12 @@ //! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame //! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the //! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time. +//! Animated WebP ([`crate::webp_anim`]) shares [`Anim`] and this decode path. use std::io::Cursor; use effectcraft_project::{AlphaMode, Footage, FootageKind}; -use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use effectcraft_time::{FrameRate, Tick}; use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder}; use crate::{MediaError, Result}; @@ -15,19 +16,20 @@ use crate::{MediaError, Result}; /// The `Footage::codec` of an animated GIF. pub(crate) const CODEC: &str = "GIF"; -/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes (less in a browser tab, -/// whose wasm32 heap is 4 GiB at most and shared with everything else). +/// Decoded frames of one GIF or animated WebP are kept as 8-bit RGBA, up to this many bytes (less +/// in a browser tab, whose wasm32 heap is 4 GiB at most and shared with everything else). #[cfg(not(target_arch = "wasm32"))] -const MAX_BYTES: usize = 1 << 30; +pub(crate) const MAX_BYTES: usize = 1 << 30; #[cfg(target_arch = "wasm32")] -const MAX_BYTES: usize = 256 << 20; +pub(crate) const MAX_BYTES: usize = 256 << 20; -/// Frames read from a GIF's headers when probing. -const MAX_FRAMES: usize = 100_000; +/// Frames read from a GIF's or animated WebP's headers when probing. +pub(crate) const MAX_FRAMES: usize = 100_000; -/// Whether `f` is an animated GIF imported by [`probe`]. -pub(crate) fn is_gif(f: &Footage) -> bool { - f.kind == FootageKind::Video && f.codec == CODEC +/// Whether `f` is an animated GIF or WebP imported by [`probe`] or [`crate::webp_anim::probe`]: +/// its frames come from an [`Anim`]. +pub(crate) fn is_animated(f: &Footage) -> bool { + f.kind == FootageKind::Video && (f.codec == CODEC || f.codec == crate::webp_anim::CODEC) } /// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders @@ -36,7 +38,7 @@ fn delay_cs(raw: u32) -> u32 { if raw < 2 { 10 } else { raw } } -fn gcd(a: u32, b: u32) -> u32 { +pub(crate) fn gcd(a: u32, b: u32) -> u32 { if b == 0 { a } else { gcd(b, a % b) } } @@ -73,19 +75,27 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { }) } -/// Every frame of a GIF, composited onto the full canvas. +/// Every frame of a GIF or animated WebP, composited onto the full canvas. pub(crate) struct Anim { frames: Vec, - /// When each frame starts, in hundredths of a second. - starts: Vec, + /// When each frame starts (ascending, one per frame). + starts: Vec, } impl Anim { - /// Decode all frames. A damaged tail keeps the frames before it. + /// Frames and the times they start at (one each, ascending); `None` when there are none. + pub(crate) fn new(frames: Vec, starts: Vec) -> Option { + (!frames.is_empty() && frames.len() == starts.len()).then_some(Anim { frames, starts }) + } + + /// Decode all frames of a GIF or animated WebP. A damaged tail keeps the frames before it. pub(crate) fn load(bytes: &[u8]) -> Result { + if crate::webp_anim::is_webp(bytes) { + return crate::webp_anim::load(bytes); + } let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; let (mut frames, mut starts) = (Vec::new(), Vec::new()); - let (mut at, mut size) = (0u64, 0usize); + let (mut at, mut size) = (0i64, 0usize); for frame in dec.into_frames() { let frame = match frame { Ok(f) => f, @@ -102,21 +112,17 @@ impl Anim { if size > MAX_BYTES { return Err(MediaError::Unsupported("GIF is too large to hold in memory".into())); } - starts.push(at); - at = at.saturating_add(u64::from(cs)); + starts.push(Tick::from_units(at, 100)); + at = at.saturating_add(i64::from(cs)); frames.push(DynamicImage::ImageRgba8(buf)); } - if frames.is_empty() { - return Err(MediaError::Decode("GIF: no frames".into())); - } - Ok(Anim { frames, starts }) + Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("GIF: no frames".into())) } - /// The frame shown at source time `t` (the last one past the end). + /// The frame shown at source time `t` (the first one before the start, the last one past the + /// end). pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> { - let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128; - let cs = u64::try_from(cs).unwrap_or(u64::MAX); - let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1); + let i = self.starts.partition_point(|&s| s <= t).saturating_sub(1); self.frames.get(i).or(self.frames.last()) } } diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index e65aa793..0fb40d81 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -40,6 +40,12 @@ fn is_pdf_like(path: &str, bytes: &[u8]) -> bool { effectcraft_pdf::sniff(bytes).is_some() && (effectcraft_pdf::EXTENSIONS.contains(&ext.as_str()) || bytes.starts_with(b"%PDF")) } +/// Whether `bytes` (the first 64 KiB suffice) are a Photoshop or vector file [`probe`] handles. +#[cfg_attr(not(any(unix, windows)), allow(dead_code))] +pub(crate) fn sniff(path: &str, bytes: &[u8]) -> bool { + effectcraft_psd::is_psd(bytes) || is_svg(path, bytes) || is_pdf_like(path, bytes) +} + /// Footage for a Photoshop or vector file (`None` for other formats). pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { if effectcraft_psd::is_psd(bytes) { @@ -81,7 +87,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) - let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))? } else { - let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; + let img = crate::probe::load_oriented(data, None).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?; let rgba = img.to_rgba32f(); effectcraft_psd::Pixels { width: rgba.width(), height: rgba.height(), data: rgba.pixels().map(|p| p.0).collect() } }; diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c1624..04a9d95e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -7,21 +7,25 @@ //! memory-budgeted LRU frame cache, one decoder per source, and the decoder kept positioned for //! sequential playback. Also audio ([`MediaPool::audio_samples`]) and thumbnails. //! -//! Video containers (MP4/MOV, Matroska/WebM) and codecs (H.264, HEVC, VP9, AV1, ProRes, DNxHD, -//! MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, +//! Video containers (MP4/MOV, Matroska/WebM, MXF OP1a / OP-Atom) and codecs (H.264, HEVC, VP9, +//! AV1, ProRes, DNxHD, MPEG-2, MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency, //! pinned; `plan/adr/0001`). FilmCraft types never leave this crate. Stills use the `image` crate -//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR); Photoshop documents (merged image or one layer) come +//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR, Targa, Radiance HDR); Photoshop documents (merged image or one layer) come //! from `effectcraft-psd` and SVG from `effectcraft-svg` (rasterised at any scale). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; +pub mod downmix; +mod dpx; pub mod exr_channels; mod gif_anim; mod layered; pub use layered::vector_doc; mod pool; mod probe; +mod stream; +mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model}; @@ -35,6 +39,9 @@ pub enum MediaError { Unsupported(String), #[error("decode error: {0}")] Decode(String), + /// The read was called off before it finished (see [`effectcraft_render::FootageSource::cancellable`]). + #[error("cancelled")] + Cancelled, } pub type Result = std::result::Result; @@ -45,20 +52,22 @@ impl From for MediaError { match e { F::Unsupported(s) => MediaError::Unsupported(s), F::Io(s) | F::Offline(s) => MediaError::Io(s), + F::Cancelled => MediaError::Cancelled, other => MediaError::Decode(other.to_string()), } } } /// File extensions recognised as stills. -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "psd", "psb", "svg", "pdf", "ai", "eps"]; +pub const STILL_EXTENSIONS: &[&str] = + &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; /// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"]; +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; /// File extensions recognised as 3D models (Advanced 3D model layers). -pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"]; +pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; /// Whether `path` has an extension we can import. pub fn is_importable(path: &std::path::Path) -> bool { @@ -67,12 +76,26 @@ pub fn is_importable(path: &std::path::Path) -> bool { || STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) + || dpx::EXTENSIONS.contains(&ext.as_str()) } pub(crate) fn ext_of(path: &std::path::Path) -> String { path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() } +/// The `image` crate format of a still: from its signature, else from its extension (Targa has +/// no signature; a `.hdr` without the Radiance one then fails with the decoder's own error). +pub(crate) fn still_format(path: &str, bytes: &[u8]) -> Option { + if let Ok(fmt) = image::guess_format(bytes) { + return Some(fmt); + } + match ext_of(std::path::Path::new(path)).as_str() { + "tga" => Some(image::ImageFormat::Tga), + "hdr" => Some(image::ImageFormat::Hdr), + _ => None, + } +} + pub(crate) fn rate_from_fc(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate { if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c56..98c70710 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,11 +6,12 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; -use effectcraft_render::FootageSource; +use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; +use image::metadata::Orientation; -use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled}; +use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; @@ -38,8 +39,8 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, frame: i64, - /// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded - /// pixels. + /// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB, + /// as stored, a 2.4 power curve): they change the decoded pixels. interp: u8, matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). @@ -170,9 +171,10 @@ struct CacheState { last: HashMap, i64>, } -/// A movie/audio source by path, opened by the first caller while the others wait for it +/// A movie/audio source by path and the turn its picture is shown with +/// ([`crate::probe::video_turn`]), opened by the first caller while the others wait for it /// (`None`: failed to open; not retried until `forget`). -type SourceSlot = Arc>>; +type SourceSlot = Arc>>; /// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). type GifSlot = Arc, String>>>; @@ -186,7 +188,7 @@ struct Inner { files: Mutex>>, /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). models: Mutex>>>, - /// Decoded animated GIFs by path, loaded by the first caller while the others wait (an + /// Decoded animated GIFs and WebPs by path, loaded by the first caller while the others wait (an /// `Err` is not retried until `forget`). gifs: Mutex, GifSlot>>, cache: Mutex, @@ -206,6 +208,9 @@ struct Inner { /// audio is read from the decoder instead of trying again on every read. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] conform_failed: Mutex>, + /// Movies that failed to open or decode (path to a readable reason; see + /// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not. + problems: Mutex, String>>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -233,6 +238,8 @@ struct Loc { key: Key, /// Media time for movies (`None`: an image file, `key.path`). media_t: Option, + /// How float values are encoded (also in `key.interp`). + float: FloatCurve, } /// Removes an in-flight key and wakes waiters, also when decoding panics. @@ -273,6 +280,7 @@ impl MediaPool { conform: Mutex::default(), conforming: Mutex::default(), conform_failed: Mutex::default(), + problems: Mutex::default(), }), } } @@ -320,6 +328,7 @@ impl MediaPool { pub fn clear(&self) { self.clear_frames(); lock(&self.inner.sources).clear(); + lock(&self.inner.problems).clear(); } /// Forget a file (after it changed on disk, or to retry a file that failed to open). @@ -327,6 +336,7 @@ impl MediaPool { lock(&self.inner.sources).remove(path); lock(&self.inner.models).remove(path); lock(&self.inner.gifs).remove(path); + lock(&self.inner.problems).remove(path); let mut c = lock(&self.inner.cache); let keys: Vec = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect(); for k in keys { @@ -345,18 +355,23 @@ impl MediaPool { /// Decode (or fetch from cache) the frame of `footage` at source time `t`. pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result> { - Inner::frame_at(&self.inner, footage, t, true, None) + Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb) } /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. - fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { if footage.missing { return None; } - match Inner::frame_at(&self.inner, footage, t, true, size) { + match Inner::frame_at(&self.inner, footage, t, true, size, float) { Ok(img) => Some(img), + // nobody wants the frame any more (`cancellable`): not an error + Err(MediaError::Cancelled) => None, + // (a movie's failure is logged once per file, when it is recorded) Err(e) => { - log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); + if lock(&self.inner.problems).get(footage.path.as_str()).is_none() { + log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); + } None } } @@ -448,8 +463,8 @@ impl MediaPool { } /// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at - /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond - /// the first two are dropped. Silence where there is no audio (or before the media starts). + /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; multichannel + /// sources are downmixed to stereo ([`crate::downmix`]). Silence where there is no audio (or before the media starts). /// With a conformed audio folder, reads come from the conformed file once it is written. pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { if !footage.has_audio || footage.missing || rate == 0 || frames == 0 { @@ -520,7 +535,7 @@ impl MediaPool { /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result { - let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?; + let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None, FloatCurve::Srgb)?; let (w, h) = (img.width.max(1), img.height.max(1)); let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0); if s >= 1.0 { @@ -572,17 +587,34 @@ impl Inner { /// it again: a preview's frame workers, audio feeder, conform and waveform threads each /// decoded a long FLAC in full at once (#408). fn source(&self, path: &Arc) -> Option { + self.opened(path).map(|(src, _)| src) + } + + /// [`Self::source`] and the turn the movie's picture is shown with. + fn opened(&self, path: &Arc) -> Option<(SharedSource, Orientation)> { let slot = lock(&self.sources).entry(path.clone()).or_default().clone(); slot.get_or_init(|| { self.opened.fetch_add(1, Ordering::Relaxed); - let opened = self.read(path).and_then(|bytes| { - let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from) + // Files on disk are read in place (the index, then samples on demand) rather than + // whole; in-memory files (`add_bytes`) and wasm32 builds open from bytes. + let in_memory = lock(&self.files).contains_key(&**path); + let file = std::path::Path::new(&**path); + let in_place = if in_memory { None } else { crate::stream::open_source(file) }; + let in_place = in_place.map(|src| { + let turn = crate::stream::movie_header(file).map_or(Orientation::NoTransforms, |h| crate::probe::video_turn(&h)); + src.map(|src| (src, turn)) + }); + let opened = in_place.unwrap_or_else(|| { + self.read(path).and_then(|bytes| { + let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let turn = crate::probe::video_turn(&bytes); + filmcraft_codecs::open_bytes(&name, bytes).map(|src| (src, turn)).map_err(MediaError::from) + }) }); match opened { Ok(s) => Some(s), Err(e) => { - log::warn!("media: cannot open {path}: {e}"); + self.note_problem(path, "", &e); None } } @@ -590,7 +622,21 @@ impl Inner { .clone() } - /// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the + /// Remember why the movie at `path` cannot be shown (`codec` is the footage's, when known) + /// and log it the first time. I/O errors (missing or unreadable file) are not decoder + /// problems and are left to the missing-footage handling. + fn note_problem(&self, path: &Arc, codec: &str, e: &MediaError) { + // A decode called off for a newer frame (#329) says nothing about the file either. + if matches!(e, MediaError::Io(_) | MediaError::Cancelled) { + return; + } + let reason = describe_problem(codec, e); + if lock(&self.problems).insert(path.clone(), reason.clone()).is_none() { + log::warn!("media: {path}: {reason}"); + } + } + + /// The decoded frames of the animated GIF or WebP at `path`. The first caller decodes it, outside the /// map's lock; callers asking for the same file meanwhile wait for that. fn gif(&self, path: &Arc) -> Result> { let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); @@ -618,22 +664,28 @@ impl Inner { ((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64 } - fn locate(footage: &Footage, t: Tick) -> Loc { + fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc { let alpha = match footage.alpha { AlphaMode::Straight => 0, AlphaMode::Premultiplied => 1, AlphaMode::Ignore => 2, }; - let interp = alpha | (u8::from(footage.preserve_rgb) << 2); + let float = AlphaOp::of(footage, float).float_curve(); + let interp = alpha + | (match float { + FloatCurve::Srgb => 0, + FloatCurve::Linear => 1, + FloatCurve::Gamma24 => 2, + } << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); match footage.sequence_file(i) { - Some(path) => Loc { key: key(path, 0), media_t: None }, + Some(path) => Loc { key: key(path, 0), media_t: None, float }, // A gap in the numbering: colour bars, as in After Effects. - None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None }, + None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float }, } } // A layer of a layered still is keyed by its layer index and size mode (smart objects @@ -641,9 +693,9 @@ impl Inner { FootageKind::Still if footage.layer.is_some() || footage.page != 0 => { let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 }) + ((footage.page as i64) << 42); - Loc { key: key(&footage.path, l), media_t: None } + Loc { key: key(&footage.path, l), media_t: None, float } } - FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None }, + FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None, float }, FootageKind::Video | FootageKind::Audio => { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); @@ -651,18 +703,18 @@ impl Inner { // Middle of the frame in the file's own timing (robust to timestamp rounding). let native = footage.native_rate.unwrap_or(rate); let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); - Loc { key: key(&footage.path, i), media_t: Some(mt) } + Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } } /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that /// size directly (see [`FootageSource::frame_at_size`]). - fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result> { + fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } - let mut loc = Self::locate(footage, t); + let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); if movie && let Some(size) = size { loc.key.size = size; @@ -673,10 +725,10 @@ impl Inner { let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { let next_t = t + footage.frame_rate.frame_duration(); - let mut next = Self::locate(footage, next_t); + let mut next = Self::locate(footage, next_t, float); next.key.size = loc.key.size; if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size); + self.prefetch(footage, next_t, size, float); } } } @@ -709,25 +761,28 @@ impl Inner { } #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false, size); + let _ = me.frame_at(&f, t, false, size, float); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} fn decode(&self, loc: &Loc, footage: &Footage) -> Result { - let op = AlphaOp::of(footage); + let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; match loc.media_t { None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), None => { let bytes = self.read(path)?; + if let Some(img) = crate::dpx::decode(path, &bytes)? { + return Ok(dynamic_to_image(&img, op)); + } if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } @@ -738,32 +793,39 @@ impl Inner { { return Err(MediaError::Decode(format!("{path}: {why}"))); } - let img = match image::load_from_memory(&bytes) { + let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); + let img = match loaded { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, }; Ok(dynamic_to_image(&img, op)) } - Some(mt) if gif_anim::is_gif(footage) => { + Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; Ok(dynamic_to_image(frame, op)) } Some(mt) => { - let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; - let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?; + let (src, turn) = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; + let vf = src + .video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))) + .map_err(MediaError::from) + .inspect_err(|e| self.note_problem(path, &footage.codec, e))?; + // A movie shown turned a quarter (a phone held upright): the footage's size and + // the render's are the turned picture's, the decoded frame's are the file's. + let side = |(w, h): (u32, u32)| if crate::probe::swaps_sides(turn) { (h, w) } else { (w, h) }; // A reduced-resolution render's frame, made at its size (a frame of the footage's // own size only, as the renderer resamples only those). - let (w, h) = loc.key.size; + let (w, h) = side(loc.key.size); if w > 0 - && (vf.width, vf.height) == (footage.width, footage.height) + && (vf.width, vf.height) == side((footage.width, footage.height)) && let Some(img) = frame_to_image_scaled(&vf, w, h) { - return Ok(img); + return Ok(turned(img, turn)); } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); - Ok(frame_to_image_in(&vf, op, buf)) + Ok(turned(frame_to_image_in(&vf, op, buf), turn)) } } } @@ -791,11 +853,27 @@ impl FootageSource for MediaPool { fn cache_budget(&self) -> Option { Some(self.budget()) } + /// A movie's decoder gives up on a seek (before it, and between samples while it decodes + /// forward from the keyframe) once `cancel` is set: a scrub's superseded frames no longer + /// hold the decoder the frame under the pointer waits for (#329). + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + filmcraft_media::cancel::with_cancel(cancel, f) + } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.frame_sized(item, footage, t, None) + self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { - self.frame_sized(item, footage, t, Some((width, height))) + self.frame_sized(item, footage, t, Some((width, height)), FloatCurve::Srgb) + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.frame_sized(item, footage, t, size, float) + } + + fn problem(&self, footage: &Footage) -> Option { + if footage.missing || footage.kind != FootageKind::Video { + return None; + } + lock(&self.inner.problems).get(footage.path.as_str()).cloned() } fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { @@ -810,7 +888,7 @@ impl FootageSource for MediaPool { if footage.missing || !footage.has_video { return None; } - let loc = Inner::locate(footage, t); + let loc = Inner::locate(footage, t, FloatCurve::Srgb); if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") { return None; } @@ -828,7 +906,7 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels; silence before the start. An error where decoding fails. +/// channels, multichannel sources are downmixed ([`crate::downmix`]); silence before the start. An error where decoding fails. fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); @@ -836,15 +914,28 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re return Ok(out); } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; - let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) }; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) { - *o0 = *a; - *o1 = *b; - } + let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); + crate::downmix::downmix_into(&buf.channels, aac, dst); Ok(out) } +/// The one-line reason a movie shows no picture: its `codec` (the probe's, e.g. "H.264 (High +/// 4:2:2)") and what the decoder said, without the error wrappers it passed through. +fn describe_problem(codec: &str, e: &MediaError) -> String { + let mut detail = match e { + MediaError::Io(s) | MediaError::Unsupported(s) | MediaError::Decode(s) => s.as_str(), + MediaError::Cancelled => "the decode was called off", + }; + let mut unsupported = matches!(e, MediaError::Unsupported(_)); + while let Some(rest) = ["decode error: ", "unsupported media: ", "unsupported: ", "I/O error: "].iter().find_map(|p| detail.strip_prefix(p)) { + unsupported |= detail.starts_with("unsupported"); + detail = rest; + } + let what = if codec.is_empty() { "This video" } else { codec }; + if unsupported { format!("{what} isn't supported by the built-in decoder: {detail}") } else { format!("{what} can't be decoded: {detail}") } +} + fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } @@ -1018,4 +1109,35 @@ mod tests { assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]); assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart"); } + + /// #750: float footage is handed over as the renderer asks: sRGB-encoded, with Working + /// Gamma 2.4's pure power curve, or as stored (Interpret As Linear Light encodes it then); + /// Preserve RGB reads it as stored whatever is asked. Each decodes and caches apart. + #[test] + fn float_exr_is_encoded_with_the_requested_curve() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("R", 0.051), ch("G", 0.18), ch("B", 2.0), ch("A", 1.0)].into()); + let image = exr::image::Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let pool = MediaPool::new(); + pool.add_bytes("/linear.exr", b.clone()); + let f = crate::probe_bytes("/linear.exr", b).unwrap(); + let px = |f: &Footage, float| pool.frame_with(ItemId(1), f, Tick::ZERO, None, float).unwrap().get(0, 0); + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 1e-3); + let srgb = px(&f, FloatCurve::Srgb); + assert!(near(srgb, [0.2503, 0.4614, 1.3532]), "sRGB: {srgb:?}"); + assert_eq!(pool.frame(ItemId(1), &f, Tick::ZERO).unwrap().get(0, 0), srgb, "frame() is sRGB"); + let g24 = px(&f, FloatCurve::Gamma24); + assert!(near(g24, [0.2898, 0.4898, 2.0f32.powf(1.0 / 2.4)]), "2.4: {g24:?}"); + assert_eq!(px(&f, FloatCurve::Linear), [0.051, 0.18, 2.0, 1.0]); + let preserve = Footage { preserve_rgb: true, ..f.clone() }; + assert_eq!(px(&preserve, FloatCurve::Gamma24), [0.051, 0.18, 2.0, 1.0]); + // Each curve is cached apart: asking again returns the same pixels. + assert_eq!(px(&f, FloatCurve::Srgb), srgb); + assert_eq!(px(&f, FloatCurve::Gamma24), g24); + } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fd..bae714c8 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use effectcraft_project::{AlphaMode, Footage, FootageKind}; use effectcraft_time::{FrameRate, Tick}; +use image::metadata::Orientation; use crate::{MediaError, Result, ext_of, rate_from_fc}; @@ -15,6 +16,13 @@ pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30; /// (`effectcraft_engine::sequence`) and probes their first frame here. pub fn probe(path: impl AsRef) -> Result { let path = path.as_ref(); + // A movie is read in place (its index), not loaded whole. + if let Some(src) = crate::stream::probe_source(path) { + let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); + let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + turn_footage(&mut f, turn); + return Ok(f); + } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) { let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); @@ -23,7 +31,7 @@ pub fn probe(path: impl AsRef) -> Result { probe_bytes(&path.to_string_lossy(), bytes.into()) } -/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its +/// Probe a 3D model (glTF 2.0 / OBJ / STL / FBX): a [`FootageKind::Model`] item whose duration is its /// longest animation. `resolve` reads sibling resources. pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?; @@ -31,6 +39,8 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option "OBJ", + Some("stl") => "STL", + Some("fbx") => "FBX", Some("glb") => "GLB", _ => "glTF", }; @@ -58,24 +68,47 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option) -> Result { + // DPX / Cineon film scans (our own reader; `image` has none). + if let Some(f) = crate::dpx::probe(path, &bytes)? { + return Ok(f); + } if let Some(f) = crate::layered::probe(path, &bytes)? { return Ok(f); } if crate::MODEL_EXTENSIONS.contains(&ext_of(Path::new(path)).as_str()) { return probe_model(path, &bytes, &|_| None); } - if let Ok(fmt) = image::guess_format(&bytes) { + if let Some(fmt) = crate::still_format(path, &bytes) { // An animated GIF is a movie; one with a single frame is a still. if fmt == image::ImageFormat::Gif && let Some(f) = crate::gif_anim::probe(path, &bytes) { return Ok(f); } + // So is an animated WebP; a still WebP stays a still. + if fmt == image::ImageFormat::WebP + && let Some(f) = crate::webp_anim::probe(path, &bytes) + { + return Ok(f); + } return probe_still_bytes(path, &bytes, fmt); } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let turn = video_turn(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; - Ok(footage_from_info(path, src.info())) + let mut f = footage_from_info(path, src.info()); + turn_footage(&mut f, turn); + Ok(f) +} + +/// The size as displayed: a movie shot with the camera on its side trades width and height. +fn turn_footage(f: &mut Footage, turn: Orientation) { + if swaps_sides(turn) { + std::mem::swap(&mut f.width, &mut f.height); + if f.pixel_aspect > 0.0 { + f.pixel_aspect = 1.0 / f.pixel_aspect; + } + } } pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage { @@ -165,7 +198,12 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { - Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), + Ok(mut dec) => { + // The size as displayed: an Exif orientation of a quarter turn swaps the sides. + let (w, h) = dec.dimensions(); + let (w, h) = if swaps_sides(dec.orientation().unwrap_or(Orientation::NoTransforms)) { (h, w) } else { (w, h) }; + (w, h, dec.color_type().has_alpha()) + } Err(e) => { // A multi-layer OpenEXR file without an unnamed RGB layer. let img = (fmt == image::ImageFormat::OpenExr).then(|| crate::exr_channels::layered_image(bytes)).flatten(); @@ -175,3 +213,44 @@ fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Resul }; Ok(still_footage(path, w, h, fmt, has_alpha)) } + +/// Whether an Exif orientation turns the picture a quarter turn (its width and height trade places). +pub(crate) fn swaps_sides(o: Orientation) -> bool { + matches!(o, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) +} + +/// Decode a still the way it is meant to be seen: its Exif orientation (a camera held sideways, a +/// mirrored scan) is applied to the pixels, as image viewers do. A file without +/// the tag, or with a broken one, decodes as stored. `fmt`: the format when the file's extension +/// decides it (Targa has no signature to guess from), else guessed from the bytes. +pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> image::ImageResult { + use image::ImageDecoder; + let reader = match fmt { + Some(fmt) => image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt), + None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?, + }; + let mut dec = reader.into_decoder()?; + let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms); + let mut img = image::DynamicImage::from_decoder(dec)?; + img.apply_orientation(orientation); + Ok(img) +} + +/// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and +/// says so in its video track's header): the quarter turn nearest the track's rotation. Only the +/// rotation counts; any mirroring, scaling or offset in the header is ignored, and other +/// containers have none. +pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { + let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; + let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + return Orientation::NoTransforms; + }; + // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. + let (a, b) = (m[0] as i64, m[1] as i64); + match (a.abs() >= b.abs(), a >= 0, b >= 0) { + (true, true, _) => Orientation::NoTransforms, + (true, false, _) => Orientation::Rotate180, + (false, _, true) => Orientation::Rotate90, + (false, _, false) => Orientation::Rotate270, + } +} diff --git a/crates/media/src/stream.rs b/crates/media/src/stream.rs new file mode 100644 index 00000000..447e239d --- /dev/null +++ b/crates/media/src/stream.rs @@ -0,0 +1,162 @@ +//! Movie and audio files read in place: the container's index now, single samples on demand. +//! +//! Reading a file whole before opening it made a clip cost its full size in memory (twice while +//! probing: the file's bytes plus the demuxer's copy), so a 45-minute movie took gigabytes just to +//! import. FilmCraft's reader openers (MP4/MOV, Matroska/WebM, MXF, Ogg, MPEG-PS/TS) open from a +//! random-access [`ByteReader`]; [`FileReader`] serves them positional reads from the open file. +//! Formats decoded in one go (stills, WAV, FLAC, MP3…) still read the whole file, as before. +//! Builds without a native file system (wasm32) keep using in-memory bytes. + +use std::path::Path; + +use filmcraft_media::SharedSource; + +use crate::Result; + +/// A file on disk read in place: it stays open and only the requested ranges are read. +#[cfg(any(unix, windows))] +#[derive(Debug)] +pub(crate) struct FileReader { + file: std::fs::File, + len: u64, +} + +#[cfg(any(unix, windows))] +impl FileReader { + pub(crate) fn open(path: &Path) -> std::io::Result { + let file = std::fs::File::open(path)?; + let m = file.metadata()?; + if !m.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{} is not a file", path.display()))); + } + Ok(Self { file, len: m.len() }) + } +} + +#[cfg(any(unix, windows))] +impl filmcraft_media::ByteReader for FileReader { + fn len(&self) -> u64 { + self.len + } + + /// Positional reads: threads decoding different parts of the file share no cursor. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_exact_at(&self.file, buf, offset) + } + #[cfg(windows)] + { + let (mut done, mut at) = (0usize, offset); + while let Some(rest) = buf.get_mut(done..).filter(|r| !r.is_empty()) { + match std::os::windows::fs::FileExt::seek_read(&self.file, rest, at) { + Ok(0) => return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file")), + Ok(n) => { + done = done.saturating_add(n); + at = at.saturating_add(n as u64); + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(()) + } + } +} + +#[cfg(any(unix, windows))] +fn name_of(path: &Path) -> String { + path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default() +} + +/// Open a movie/audio file for the media pool, reading only what its container needs; files the +/// reader openers don't handle are read whole and opened from memory, exactly as before. `None` +/// when the file can't be opened in place (the caller reads it whole, which reports the error). +#[cfg(any(unix, windows))] +pub(crate) fn open_source(path: &Path) -> Option> { + let reader = FileReader::open(path).ok()?; + let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); + let opened = filmcraft_media::reader::open_reader(&name_of(path), reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()); + Some(opened.map_err(Into::into)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn open_source(_path: &Path) -> Option> { + None +} + +/// The leading `ftyp` and the `moov` box of an ISO-BMFF movie on disk (all a track's display +/// matrix needs), without reading the samples: top-level box headers are walked with positional +/// reads. `None` when the file isn't ISO-BMFF or its `moov` is missing or implausibly large. +#[cfg(any(unix, windows))] +pub(crate) fn movie_header(path: &Path) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + const MAX_MOOV: u64 = 64 << 20; + let mut f = std::fs::File::open(path).ok()?; + let len = f.metadata().ok()?.len(); + let mut out = Vec::new(); + let mut pos = 0u64; + for _ in 0..4096 { + if pos.checked_add(8)? > len { + return None; + } + f.seek(SeekFrom::Start(pos)).ok()?; + let mut h = [0u8; 16]; + f.read_exact(&mut h[..8]).ok()?; + let kind = [h[4], h[5], h[6], h[7]]; + let mut size = u64::from(u32::from_be_bytes([h[0], h[1], h[2], h[3]])); + if size == 1 { + f.read_exact(&mut h[8..16]).ok()?; + size = u64::from_be_bytes([h[8], h[9], h[10], h[11], h[12], h[13], h[14], h[15]]); + } else if size == 0 { + size = len - pos; + } + if size < 8 || pos.checked_add(size)? > len { + return None; + } + if kind == *b"ftyp" || kind == *b"moov" { + if size > MAX_MOOV { + return None; + } + f.seek(SeekFrom::Start(pos)).ok()?; + let mut b = vec![0u8; usize::try_from(size).ok()?]; + f.read_exact(&mut b).ok()?; + out.extend_from_slice(&b); + if kind == *b"moov" { + return Some(out); + } + } + pos += size; + } + None +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn movie_header(_path: &Path) -> Option> { + None +} + +/// Probing in place: the opened container when `path` is a movie/audio container a reader opener +/// handles and nothing probed before containers (stills, layered and vector documents, 3D models) +/// claims it. `None`: probe the whole file's bytes as before. +#[cfg(any(unix, windows))] +pub(crate) fn probe_source(path: &Path) -> Option> { + let ext = crate::ext_of(path); + if crate::STILL_EXTENSIONS.contains(&ext.as_str()) || crate::MODEL_EXTENSIONS.contains(&ext.as_str()) { + return None; + } + let reader = FileReader::open(path).ok()?; + let reader: filmcraft_media::SharedReader = std::sync::Arc::new(reader); + let head = filmcraft_media::reader::read_range(&*reader, 0, filmcraft_media::reader::HEAD_LEN).ok()?; + let name = path.to_string_lossy(); + if crate::layered::sniff(&name, &head) || image::guess_format(&head).is_ok() { + return None; + } + let name = name_of(path); + filmcraft_codecs::reader_openers().into_iter().find_map(|open| open(&name, &head, &reader)).map(|r| r.map_err(Into::into)) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn probe_source(_path: &Path) -> Option> { + None +} diff --git a/crates/media/src/webp_anim.rs b/crates/media/src/webp_anim.rs new file mode 100644 index 00000000..9420be1e --- /dev/null +++ b/crates/media/src/webp_anim.rs @@ -0,0 +1,329 @@ +//! Animated WebP: probed as movies and decoded whole into 8-bit frames, like animated GIF +//! ([`crate::gif_anim`], whose [`Anim`] holds the frames). +//! +//! An animated WebP is a RIFF file with a `VP8X` header whose animation flag is set and one `ANMF` +//! chunk per frame, each with its own duration in milliseconds. Evenly spaced frames get the rate +//! they are spaced at. Encoders round the frame times of a rate like 24 or 29.97 frames a second to +//! whole milliseconds (41, 42, 42, 41… ms), so delays that differ by at most one millisecond are +//! read as the standard rate whose frame times they round to; any other timing gets the rate that +//! every delay is a whole number of frames at (1000 / the greatest common divisor of the delays), +//! so it is kept exactly. A still WebP (no animation flag, or a single frame) stays a still. + +use std::io::Cursor; + +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::{FrameRate, Tick}; +use image::{AnimationDecoder, DynamicImage, codecs::webp::WebPDecoder}; + +use crate::gif_anim::{Anim, MAX_BYTES, MAX_FRAMES, gcd}; +use crate::{MediaError, Result}; + +/// The `Footage::codec` of an animated WebP. +pub(crate) const CODEC: &str = "WebP"; + +/// Whether `bytes` start like a WebP file. +pub(crate) fn is_webp(bytes: &[u8]) -> bool { + bytes.get(0..4) == Some(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") +} + +/// What the chunk headers of an animated WebP say. +struct Header { + width: u32, + height: u32, + alpha: bool, + /// Each frame's delay in milliseconds, as stored. + delays: Vec, +} + +fn u24(b: &[u8]) -> Option { + match b { + [a, b, c, ..] => Some(u32::from(*a) | u32::from(*b) << 8 | u32::from(*c) << 16), + _ => None, + } +} + +/// Read the `VP8X` header and the `ANMF` frame headers. `None` when the file isn't an animated +/// WebP. A truncated tail keeps the frames before it. +fn header(bytes: &[u8]) -> Option
{ + if !is_webp(bytes) { + return None; + } + let riff = bytes.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; + let end = usize::try_from(riff).ok()?.saturating_add(8).min(bytes.len()); + let (mut at, mut canvas, mut delays) = (12usize, None, Vec::new()); + while delays.len() < MAX_FRAMES { + let Some(head) = bytes.get(at..at.saturating_add(8)).filter(|_| at.saturating_add(8) <= end) else { break }; + let size = head.get(4..8).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes)?; + let size = usize::try_from(size).ok()?; + let body_end = at.saturating_add(8).saturating_add(size); + let Some(body) = bytes.get(at.saturating_add(8)..body_end).filter(|_| body_end <= end) else { break }; + match head.get(0..4) { + Some(b"VP8X") => { + let flags = *body.first()?; + if flags & 0x02 == 0 { + // No animation flag: a still. + return None; + } + let w = u24(body.get(4..)?)?.saturating_add(1); + let h = u24(body.get(7..)?)?.saturating_add(1); + canvas = Some((w, h, flags & 0x10 != 0)); + } + Some(b"ANMF") if body.len() >= 16 => delays.push(u24(body.get(12..)?)?), + _ => {} + } + at = body_end.saturating_add(size & 1); + } + let (width, height, alpha) = canvas?; + Some(Header { width, height, alpha, delays }) +} + +/// The delay of a frame in milliseconds. Browsers show delays of 10 ms or less (that encoders +/// write for "as fast as possible") as 100 ms, and so do we, as for GIF. +fn delay_ms(raw: u32) -> u32 { + if raw <= 10 { 100 } else { raw } +} + +/// The footage rate, when each frame starts, and the duration, for frames of these delays (ms). +struct Timing { + rate: FrameRate, + starts: Vec, + duration: Tick, +} + +fn timing(delays: &[u32]) -> Timing { + let delays: Vec = delays.iter().map(|&d| delay_ms(d)).collect(); + let mut starts_ms = Vec::with_capacity(delays.len()); + let mut total = 0i64; + for &d in &delays { + starts_ms.push(total); + total = total.saturating_add(i64::from(d)); + } + let n = i64::try_from(delays.len()).unwrap_or(i64::MAX); + let (min, max) = (delays.iter().copied().min().unwrap_or(0), delays.iter().copied().max().unwrap_or(0)); + // Frame times of a standard rate rounded to milliseconds: that rate if every frame starts (and + // the last one ends) within a millisecond of it. + let fps = if total > 0 { n.saturating_mul(1000).saturating_add(total / 2) / total } else { 0 }; + if max == min.saturating_add(1) && fps > 0 { + for rate in [FrameRate::new(fps, 1), FrameRate::new(fps.saturating_mul(1000), 1001)] { + // |start_ms - k * 1000 * den / num| <= 1, in integers. + let (num, den) = (i128::from(rate.num), i128::from(rate.den)); + let fits = starts_ms.iter().copied().chain([total]).enumerate().all(|(k, ms)| { + let k = i128::try_from(k).unwrap_or(i128::MAX); + (i128::from(ms) * num - k * 1000 * den).abs() <= num + }); + if fits { + let starts = (0..n).map(|k| rate.tick_of(k)).collect(); + return Timing { rate, starts, duration: rate.tick_of(n) }; + } + } + } + let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1); + Timing { + rate: FrameRate::new(1000, i64::from(step)), + starts: starts_ms.iter().map(|&ms| Tick::from_units(ms, 1000)).collect(), + duration: Tick::from_units(total, 1000), + } +} + +/// Describe an animated WebP as a movie. `None` for a still WebP (no animation, or one frame) or +/// one whose headers can't be read. +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { + let h = header(bytes)?; + if h.delays.len() < 2 || h.width == 0 || h.height == 0 { + return None; + } + let t = timing(&h.delays); + Some(Footage { + path: path.to_string(), + kind: FootageKind::Video, + width: h.width, + height: h.height, + frame_rate: t.rate, + duration: t.duration, + has_video: true, + has_audio: false, + alpha: if h.alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, + codec: CODEC.to_string(), + ..Default::default() + }) +} + +/// Decode every frame of an animated WebP, composited onto the full canvas. A damaged tail keeps +/// the frames before it; an animation too large to hold in memory is refused before decoding. +pub(crate) fn load(bytes: &[u8]) -> Result { + let h = header(bytes).ok_or_else(|| MediaError::Decode("WebP: not an animated WebP".into()))?; + let frame_bytes = usize::try_from(u64::from(h.width) * u64::from(h.height) * 4).unwrap_or(usize::MAX); + if frame_bytes.saturating_mul(h.delays.len()) > MAX_BYTES { + return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height))); + } + let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?; + let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize); + for frame in dec.into_frames() { + let frame = match frame { + Ok(f) => f, + Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("WebP: {e}"))), + Err(e) => { + log::warn!("media: WebP stops after {} frames: {e}", frames.len()); + break; + } + }; + let (numer, denom) = frame.delay().numer_denom_ms(); + delays.push(numer.checked_div(denom).unwrap_or(0)); + let buf = frame.into_buffer(); + size = size.saturating_add(buf.as_raw().len()); + if size > MAX_BYTES { + return Err(MediaError::Unsupported("animated WebP is too large to hold in memory".into())); + } + frames.push(DynamicImage::ImageRgba8(buf)); + } + let starts = timing(&delays).starts; + Anim::new(frames, starts).ok_or_else(|| MediaError::Decode("WebP: no frames".into())) +} + +#[cfg(test)] +mod tests { + use effectcraft_project::{AlphaMode, Footage, FootageKind}; + use effectcraft_time::{FrameRate, Tick}; + use image::{ExtendedColorType, ImageEncoder, codecs::webp::WebPEncoder}; + + use super::timing; + use crate::{MediaPool, probe_bytes}; + + fn chunk(fourcc: &[u8; 4], body: &[u8]) -> Vec { + let mut out = fourcc.to_vec(); + out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes()); + out.extend_from_slice(body); + if body.len() % 2 == 1 { + out.push(0); + } + out + } + + fn u24(v: u32) -> [u8; 3] { + let b = v.to_le_bytes(); + [b[0], b[1], b[2]] + } + + /// The `VP8L` chunk of a lossless still WebP of a solid RGBA colour. + fn vp8l(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut still = Vec::new(); + let px = rgba.repeat((w * h) as usize); + WebPEncoder::new_lossless(&mut still).write_image(&px, w, h, ExtendedColorType::Rgba8).unwrap(); + let at = still.windows(4).position(|c| c == b"VP8L").unwrap(); + still[at..].to_vec() + } + + /// A `w`×`h` animated WebP with alpha, one solid full-canvas frame per `(rgba, delay ms)`. + fn webp(w: u32, h: u32, frames: &[([u8; 4], u32)]) -> Vec { + let mut vp8x = vec![0x12, 0, 0, 0]; + vp8x.extend_from_slice(&u24(w - 1)); + vp8x.extend_from_slice(&u24(h - 1)); + let mut body = b"WEBP".to_vec(); + body.extend(chunk(b"VP8X", &vp8x)); + body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for (rgba, delay) in frames { + let mut anmf = Vec::new(); + anmf.extend_from_slice(&[0; 6]); + anmf.extend_from_slice(&u24(w - 1)); + anmf.extend_from_slice(&u24(h - 1)); + anmf.extend_from_slice(&u24(*delay)); + anmf.push(0b10); // no blending, no disposal + anmf.extend(vp8l(w, h, *rgba)); + body.extend(chunk(b"ANMF", &anmf)); + } + chunk(b"RIFF", &body) + } + + const RED: [u8; 4] = [255, 0, 0, 255]; + const GREEN: [u8; 4] = [0, 255, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 128]; + + fn pixel_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 4] { + pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap().data[0] + } + + #[test] + fn an_animated_webp_is_a_movie_with_its_frames() { + let bytes = webp(4, 2, &[(RED, 50), (GREEN, 100), (BLUE, 50)]); + let f = probe_bytes("a.webp", bytes.clone().into()).unwrap(); + assert_eq!(f.kind, FootageKind::Video); + assert!(f.has_video && !f.has_audio); + assert_eq!((f.width, f.height), (4, 2)); + assert_eq!(f.alpha, AlphaMode::Straight); + // Delays of 50, 100 and 50 ms: 20 frames a second, 0.2 s in all. + assert_eq!(f.frame_rate, FrameRate::new(20, 1)); + assert_eq!(f.duration, Tick::from_units(200, 1000)); + + let pool = MediaPool::new(); + pool.add_bytes("a.webp", bytes.into()); + let first = pixel_at(&pool, &f, 0.01); + assert_eq!(&first[..3], &[1.0, 0.0, 0.0]); + let second = pixel_at(&pool, &f, 0.06); + assert_ne!(first, second, "a later frame differs from the first"); + assert_eq!(&second[..3], &[0.0, 1.0, 0.0]); + assert_eq!(&pixel_at(&pool, &f, 0.14)[..3], &[0.0, 1.0, 0.0]); + // The last frame keeps its alpha, and holds past the end. + let last = pixel_at(&pool, &f, 0.17); + assert!((last[3] - 128.0 / 255.0).abs() < 1e-3, "{last:?}"); + assert_eq!(pixel_at(&pool, &f, 5.0), last); + } + + #[test] + fn delays_rounded_to_milliseconds_read_as_the_standard_rate() { + // 24 frames a second rounded to whole milliseconds, as ffmpeg writes them. + let delays: Vec = (0..24).map(|k| (((k + 1) * 1000 + 12) / 24 - (k * 1000 + 12) / 24) as u32).collect(); + assert!(delays.iter().all(|&d| d == 41 || d == 42)); + let t = timing(&delays); + assert_eq!(t.rate, FrameRate::new(24, 1)); + assert_eq!(t.duration, Tick::from_seconds_f64(1.0)); + assert_eq!(t.starts[5], FrameRate::new(24, 1).tick_of(5)); + // 29.97: 33 and 34 ms over a long run. + let ntsc = FrameRate::new(30_000, 1001); + let delays: Vec = (0..300).map(|k| (((k + 1) * 1_001_000 / 30_000) - (k * 1_001_000 / 30_000)) as u32).collect(); + assert_eq!(timing(&delays).rate, ntsc); + // Uneven timing is kept exactly. + assert_eq!(timing(&[40, 80, 40]).rate, FrameRate::new(25, 1)); + assert_eq!(timing(&[33, 34, 50]).rate, FrameRate::new(1000, 1)); + // As fast as possible is 100 ms, as in browsers. + assert_eq!(timing(&[0, 10]).duration, Tick::from_units(200, 1000)); + } + + #[test] + fn a_still_webp_stays_a_still() { + let mut still = Vec::new(); + WebPEncoder::new_lossless(&mut still).write_image(&RED.repeat(4), 2, 2, ExtendedColorType::Rgba8).unwrap(); + let f = probe_bytes("s.webp", still.into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + // So does an animation of one frame. + let f = probe_bytes("one.webp", webp(2, 2, &[(RED, 100)]).into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + } + + #[test] + fn a_truncated_animated_webp_is_an_error_not_a_panic() { + let full = webp(4, 2, &[(RED, 50), (GREEN, 50), (BLUE, 50)]); + let f = probe_bytes("a.webp", full.clone().into()).unwrap(); + for len in [0, 11, 20, 40, full.len() / 2, full.len() - 3] { + let bytes = &full[..len]; + let _ = probe_bytes("t.webp", bytes.to_vec().into()); + let pool = MediaPool::new(); + pool.add_bytes("a.webp", bytes.to_vec().into()); + // Cut before the second frame's data: no frames at all, or the ones before the cut. + let r = pool.frame_at(&f, Tick::ZERO); + if len < full.len() / 2 { + assert!(r.is_err(), "{len} bytes"); + } + } + } + + #[test] + fn a_huge_animation_is_refused_before_decoding() { + // 16384×16384 canvas, 10 frames: 10 GiB of RGBA. The frame data is a tiny 1×1 picture; + // the size check comes first. + let mut bytes = webp(1, 1, &[(RED, 50); 10]); + bytes[24..27].copy_from_slice(&u24(16383)); + bytes[27..30].copy_from_slice(&u24(16383)); + let e = super::load(&bytes).err().unwrap(); + assert!(e.to_string().contains("too large"), "{e}"); + } +} diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/downmix.rs new file mode 100644 index 00000000..e0e7d225 --- /dev/null +++ b/crates/media/tests/downmix.rs @@ -0,0 +1,226 @@ +//! Multichannel audio downmix (#903) against ffmpeg as an external oracle. Fixtures: ffmpeg with +//! one sine per channel, written to `target/fixtures/media`; tests skip when ffmpeg is missing. + +use std::f64::consts::PI; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use effectcraft_media::{MediaPool, probe}; +use effectcraft_time::Tick; + +const RATE: u32 = 48_000; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") +} + +/// A fixture with `layout`'s channels `names` (join order) each carrying its own sine, encoded +/// with `codec_args`. `None` when ffmpeg is missing or cannot make it. +fn fixture(name: &str, layout: &str, names: &[&str], freqs: &[u32], codec_args: &[&str]) -> Option { + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + eprintln!("ffmpeg not found: skipping"); + return None; + } + let out = fixtures().join(name); + if out.exists() { + return Some(out); + } + std::fs::create_dir_all(fixtures()).ok()?; + let ext = out.extension()?.to_string_lossy().to_string(); + let tmp = out.with_file_name(format!("tmp-{}-{name}", std::process::id())).with_extension(ext); + let mut cmd = Command::new("ffmpeg"); + cmd.args(["-hide_banner", "-loglevel", "error", "-y"]); + let mut map = String::new(); + for (i, (n, f)) in names.iter().zip(freqs).enumerate() { + cmd.args(["-f", "lavfi", "-i", &format!("sine=frequency={f}:sample_rate={RATE}:duration=2")]); + map.push_str(&format!("[{i}]")); + let _ = n; + } + let post = if name.contains("loud") { ",volume=8:precision=float,alimiter=limit=1:level=disabled" } else { "" }; + let joins: Vec = names.iter().enumerate().map(|(i, n)| format!("{i}.0-{n}")).collect(); + cmd.args(["-filter_complex", &format!("{map}join=inputs={}:channel_layout={layout}:map={}{}[a]", names.len(), joins.join("|"), post), "-map", "[a]"]); + cmd.args(codec_args).arg(&tmp); + let ok = cmd.status().ok()?.success(); + if !ok { + eprintln!("ffmpeg could not make {name}"); + return None; + } + std::fs::rename(&tmp, &out).ok()?; + Some(out) +} + +/// Amplitude (peak) of the sine at `freq` in one channel of interleaved stereo. +fn amp(s: &[f32], ch: usize, freq: u32) -> f64 { + let (mut re, mut im, mut n) = (0.0, 0.0, 0usize); + for (i, f) in s.as_chunks::<2>().0.iter().enumerate() { + let w = 2.0 * PI * f64::from(freq) * i as f64 / f64::from(RATE); + re += f64::from(f[ch]) * w.cos(); + im += f64::from(f[ch]) * w.sin(); + n += 1; + } + 2.0 * (re * re + im * im).sqrt() / n.max(1) as f64 +} + +const K: f64 = std::f64::consts::FRAC_1_SQRT_2; + +struct Case { + file: &'static str, + layout: &'static str, + names: &'static [&'static str], + codec: &'static [&'static str], + /// Expected `[left, right]` gain of each channel (the same order as `names`). + gains: Vec<[f64; 2]>, + /// Allowed deviation of a channel's amplitude (lossy codecs). + tol: f64, +} + +const FREQS: [u32; 8] = [300, 500, 700, 900, 1100, 1300, 1500, 1700]; + +fn five_one() -> (&'static [&'static str], Vec<[f64; 2]>) { + let k = 1.0 / (1.0 + 2.0 * K); + (&["FL", "FR", "FC", "LFE", "BL", "BR"], vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K]]) +} + +fn seven_one() -> (&'static [&'static str], Vec<[f64; 2]>) { + let k = 1.0 / (1.0 + 3.0 * K); + ( + &["FL", "FR", "FC", "LFE", "BL", "BR", "SL", "SR"], + vec![[k, 0.0], [0.0, k], [k * K, k * K], [0.0, 0.0], [k * K, 0.0], [0.0, k * K], [k * K, 0.0], [0.0, k * K]], + ) +} + +fn cases() -> Vec { + let (n51, g51) = five_one(); + let (n71, g71) = seven_one(); + let k5 = 1.0 / (1.0 + 2.0 * K); + let kq = 1.0 / (1.0 + K); + vec![ + Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, + Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, + Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, + Case { file: "dm71.wav", layout: "7.1", names: n71, codec: &["-c:a", "pcm_s16le"], gains: g71.clone(), tol: 0.02 }, + Case { file: "dm71.flac", layout: "7.1", names: n71, codec: &["-c:a", "flac"], gains: g71, tol: 0.02 }, + Case { + file: "dm50.wav", + layout: "5.0", + names: &["FL", "FR", "FC", "BL", "BR"], + codec: &["-c:a", "pcm_s16le"], + gains: vec![[k5, 0.0], [0.0, k5], [k5 * K, k5 * K], [k5 * K, 0.0], [0.0, k5 * K]], + tol: 0.02, + }, + Case { + file: "dmquad.wav", + layout: "quad", + names: &["FL", "FR", "BL", "BR"], + codec: &["-c:a", "pcm_s16le"], + gains: vec![[kq, 0.0], [0.0, kq], [kq * K, 0.0], [0.0, kq * K]], + tol: 0.02, + }, + ] +} + +/// Our stereo mix of `p` over one second, from 0.2 s in. +fn ours(p: &Path) -> Vec { + let f = probe(p).expect("probe"); + MediaPool::new().audio_samples(&f, Tick::from_seconds_f64(0.2), RATE as usize, RATE) +} + +/// ffmpeg's own `-ac 2` downmix of the same second, interleaved. Written as 16-bit integers: +/// swresample only normalises the matrix (to keep full scale from clipping) for integer output. +fn ffmpeg_ac2(p: &Path) -> Vec { + let out = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-ss", "0.2", "-t", "1", "-i"]) + .arg(p) + .args(["-ac", "2", "-ar", "48000", "-f", "s16le", "-"]) + .output() + .expect("run ffmpeg"); + out.stdout.as_chunks::<2>().0.iter().map(|b| f32::from(i16::from_le_bytes(*b)) / 32768.0).collect() +} + +fn rms(s: &[f32]) -> f64 { + (s.iter().map(|v| f64::from(*v).powi(2)).sum::() / s.len().max(1) as f64).sqrt() +} + +fn peak(s: &[f32]) -> f64 { + s.iter().fold(0.0f64, |m, v| m.max(f64::from(v.abs()))) +} + +/// Each channel carries its own sine at amplitude 0.5·√... (ffmpeg's `sine` is −24 dBFS RMS, +/// peak ≈ 0.125). Every channel must reach each side at the BS.775 gain, and the overall level +/// must match ffmpeg's `-ac 2`. +#[test] +fn each_channel_reaches_stereo_at_its_bs775_gain() { + for c in cases() { + let freqs: Vec = FREQS.iter().copied().take(c.names.len()).collect(); + let Some(p) = fixture(c.file, c.layout, c.names, &freqs, c.codec) else { continue }; + let s = ours(&p); + let sine = 0.125 * 1.0; // peak of ffmpeg's sine source (0.125 = 2^-3) + for (i, (name, g)) in c.names.iter().zip(&c.gains).enumerate() { + for (side, gain) in g.iter().enumerate() { + let got = amp(&s, side, freqs[i]); + let want = sine * gain; + assert!((got - want).abs() <= sine * c.tol.max(0.02), "{}: {name} -> {} is {got:.4}, expected {want:.4}", c.file, ["L", "R"][side]); + } + } + let theirs = ffmpeg_ac2(&p); + let (a, b) = (rms(&s), rms(&theirs)); + let db = 20.0 * (a / b).log10(); + assert!(db.abs() < 1.0, "{}: level {db:.2} dB off ffmpeg -ac 2 (rms {a:.4} vs {b:.4})", c.file); + let pdb = 20.0 * (peak(&s) / peak(&theirs)).log10(); + assert!(pdb.abs() < 1.5, "{}: peak {pdb:.2} dB off ffmpeg -ac 2", c.file); + } +} + +/// Full-scale in every channel must not clip. +#[test] +fn loud_surround_does_not_clip() { + let Some(p) = fixture("dm51_loud.wav", "5.1", &["FL", "FR", "FC", "LFE", "BL", "BR"], &[300; 6], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!(peak(&s) <= 1.001, "peak {}", peak(&s)); + assert!(peak(&s) > 0.5, "audible, peak {}", peak(&s)); +} + +/// Mono and stereo footage keep their level (mono on both sides). +#[test] +fn mono_and_stereo_keep_their_level() { + let Some(p) = fixture("dm10.wav", "mono", &["FC"], &[440], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && (amp(&s, 1, 440) - 0.125).abs() < 0.003); + let Some(p) = fixture("dm20.wav", "stereo", &["FL", "FR"], &[440, 660], &["-c:a", "pcm_s16le"]) else { return }; + let s = ours(&p); + assert!((amp(&s, 0, 440) - 0.125).abs() < 0.003 && amp(&s, 1, 440) < 0.003); + assert!((amp(&s, 1, 660) - 0.125).abs() < 0.003 && amp(&s, 0, 660) < 0.003); +} + +/// Odd channel counts (3, 4, 7, 9, 12, 16) decode to finite stereo without panicking. +#[test] +fn hostile_channel_counts_do_not_panic() { + for (n, layout) in [(3, "3.0"), (4, "quad"), (7, "6.1"), (9, "9 channels"), (12, "12 channels"), (16, "16 channels")] { + let names: Vec = (0..n).map(|i| format!("{i}")).collect(); + // channel names for unnamed layouts are DEFAULT-ordered by position + let _ = names; + let file = format!("dmn{n}.wav"); + let args = ["-f", "lavfi", "-i", &format!("sine=frequency=440:sample_rate={RATE}:duration=1")]; + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + return; + } + let out = fixtures().join(&file); + std::fs::create_dir_all(fixtures()).expect("mkdir"); + if !out.exists() { + let tmp = fixtures().join(format!("tmp-{}-{file}", std::process::id())); + let af = format!("pan={layout}|{}", (0..n).map(|i| format!("c{i}=c0")).collect::>().join("|")); + let ok = + Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).args(["-af", &af, "-c:a", "pcm_s16le"]).arg(&tmp).status(); + if !ok.is_ok_and(|s| s.success()) { + eprintln!("ffmpeg cannot make {n} channels: skipping"); + continue; + } + std::fs::rename(&tmp, &out).expect("rename"); + } + let Ok(f) = probe(&out) else { continue }; + let s = MediaPool::new().audio_samples(&f, Tick::ZERO, 4800, RATE); + assert_eq!(s.len(), 9600, "{n} channels"); + assert!(s.iter().all(|v| v.is_finite() && v.abs() <= 1.001), "{n} channels"); + } +} diff --git a/crates/media/tests/exif_orientation.rs b/crates/media/tests/exif_orientation.rs new file mode 100644 index 00000000..d5acc3c2 --- /dev/null +++ b/crates/media/tests/exif_orientation.rs @@ -0,0 +1,84 @@ +//! A still's Exif orientation (a camera held sideways, a mirrored scan) is applied on import: the +//! footage has the size the picture is seen at, and its frames are the picture the right way up. + +use std::sync::Arc; + +use effectcraft_media::{MediaPool, probe_bytes}; +use effectcraft_project::FootageKind; +use effectcraft_time::Tick; + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const GREEN: [f32; 3] = [0.0, 1.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; +const WHITE: [f32; 3] = [1.0, 1.0, 1.0]; + +/// A 32×16 JPEG with red, green / blue, white quarters, tagged with Exif orientation `tag` +/// (`None`: no Exif at all). +fn quartered_jpeg(tag: Option) -> Vec { + let img = image::RgbImage::from_fn(32, 16, |x, y| { + let c = match (x < 16, y < 8) { + (true, true) => RED, + (false, true) => GREEN, + (true, false) => BLUE, + (false, false) => WHITE, + }; + image::Rgb(c.map(|v| (v * 255.0) as u8)) + }); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&img).expect("encode"); + let Some(tag) = tag else { return jpeg }; + // APP1 "Exif": a big-endian TIFF header and one IFD holding Orientation (0x0112, SHORT). + let mut app1 = vec![0xFF, 0xE1, 0x00, 0x22]; + app1.extend_from_slice(b"Exif\0\0MM\0\x2A\0\0\0\x08"); + app1.extend_from_slice(&[0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, tag, 0x00, 0x00]); + app1.extend_from_slice(&[0x00; 4]); + // (Straight after the SOI marker.) + jpeg.splice(2..2, app1); + jpeg +} + +/// The footage size and the colours of its four corners (top-left, top-right, bottom-left, +/// bottom-right), as imported. +fn imported(tag: Option) -> ((u32, u32), [[f32; 3]; 4]) { + let name = format!("oriented-{tag:?}.jpg"); + let bytes: Arc<[u8]> = quartered_jpeg(tag).into(); + let f = probe_bytes(&name, bytes.clone()).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + let pool = MediaPool::new(); + pool.add_bytes(&name, bytes); + let img = pool.frame_at(&f, Tick::ZERO).expect("frame"); + assert_eq!((img.width, img.height), (f.width, f.height), "orientation {tag:?}: the frame has the probed size"); + let (w, h) = (img.width as usize, img.height as usize); + let at = |x: usize, y: usize| { + let p = img.data[y * w + x]; + [p[0], p[1], p[2]] + }; + ((f.width, f.height), [at(2, 2), at(w - 3, 2), at(2, h - 3), at(w - 3, h - 3)]) +} + +#[test] +fn exif_orientation_is_applied_to_stills() { + let (landscape, portrait) = ((32, 16), (16, 32)); + let cases = [ + (None, landscape, [RED, GREEN, BLUE, WHITE]), + (Some(1), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(2), landscape, [GREEN, RED, WHITE, BLUE]), + (Some(3), landscape, [WHITE, BLUE, GREEN, RED]), + (Some(4), landscape, [BLUE, WHITE, RED, GREEN]), + (Some(5), portrait, [RED, BLUE, GREEN, WHITE]), + (Some(6), portrait, [BLUE, RED, WHITE, GREEN]), + (Some(7), portrait, [WHITE, GREEN, BLUE, RED]), + (Some(8), portrait, [GREEN, WHITE, RED, BLUE]), + // (Not an orientation: the picture as stored.) + (Some(0), landscape, [RED, GREEN, BLUE, WHITE]), + (Some(9), landscape, [RED, GREEN, BLUE, WHITE]), + ]; + for (tag, size, corners) in cases { + let (got_size, got) = imported(tag); + assert_eq!(got_size, size, "orientation {tag:?}: size"); + for (i, (g, want)) in got.iter().zip(corners).enumerate() { + let near = g.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.2); + assert!(near, "orientation {tag:?}: corner {i} is {g:?}, expected {want:?}"); + } + } +} diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d..13ca605e 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -9,7 +9,7 @@ use std::process::Command; use std::sync::Arc; use std::time::Instant; -use effectcraft_media::{MediaPool, probe}; +use effectcraft_media::{MediaPool, probe, probe_bytes}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::Image; use effectcraft_render::FootageSource; @@ -336,6 +336,59 @@ fn resampled_audio_read_in_frame_pieces_matches_ffmpeg() { } } +/// The H.264 fixture with the rotation part of its video track header's matrix (`[a b; c d]`, +/// 16.16 fixed point) replaced: what a phone writes for a movie shot with the camera turned. +fn turned_mp4(p: &Path, abcd: [i32; 4]) -> Arc<[u8]> { + let mut bytes = std::fs::read(p).expect("read"); + // (The video track comes first; a version 0 `tkhd` has its matrix 44 bytes after the type.) + let at = bytes.windows(4).position(|w| w == b"tkhd").expect("tkhd"); + assert_eq!(bytes[at + 4], 0, "a version 0 tkhd"); + for (slot, v) in [0, 1, 3, 4].into_iter().zip(abcd) { + let o = at + 44 + slot * 4; + bytes[o..o + 4].copy_from_slice(&v.to_be_bytes()); + } + bytes.into() +} + +/// A movie whose track header says it is shown rotated (a phone held upright) imports the way it +/// is shown: the footage has the turned size and its frames are turned, at full and reduced size. +#[test] +fn rotated_mp4_imports_upright() { + let Some(p) = h264_mp4() else { return }; + let plain_footage = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let t = t_of(plain_footage.frame_rate, 3); + let plain = pool.frame(ItemId(1), &plain_footage, t).expect("frame"); + let (w, h) = (plain.width as usize, plain.height as usize); + let one = 0x1_0000; + // The matrix, the turned size and the plain frame's pixel shown at (x, y). + type From = fn(usize, usize, usize, usize) -> (usize, usize); + let cases: [(&str, [i32; 4], (usize, usize), From); 3] = [ + ("90", [0, one, -one, 0], (h, w), |x, y, _, h| (y, h - 1 - x)), + ("180", [-one, 0, 0, -one], (w, h), |x, y, w, h| (w - 1 - x, h - 1 - y)), + ("270", [0, -one, one, 0], (h, w), |x, y, w, _| (w - 1 - y, x)), + ]; + for (name, matrix, (tw, th), from) in cases { + let path = format!("turned-{name}.mp4"); + let bytes = turned_mp4(&p, matrix); + let f = probe_bytes(&path, bytes.clone()).expect("probe"); + assert_eq!((f.width as usize, f.height as usize), (tw, th), "{name}°: size"); + pool.add_bytes(&path, bytes); + let img = pool.frame(ItemId(2), &f, t).expect("frame"); + assert_eq!((img.width as usize, img.height as usize), (tw, th), "{name}°: frame size"); + for (x, y) in [(0, 0), (tw - 1, 0), (0, th - 1), (tw - 1, th - 1), (tw / 3, th / 5), (tw / 2, th - 7)] { + let (sx, sy) = from(x, y, w, h); + assert_eq!(img.data[y * tw + x], plain.data[sy * w + sx], "{name}°: pixel ({x}, {y})"); + } + // (A reduced-resolution render asks for the turned size.) + let (hw, hh) = (f.width / 2, f.height / 2); + let sized = pool.frame_at_size(ItemId(2), &f, t, hw, hh).expect("frame"); + assert_eq!((sized.width, sized.height), (hw, hh), "{name}°: reduced size"); + let db = psnr(&sized, &effectcraft_raster::resample(&img, hw, hh)); + assert!(db > 45.0, "{name}° at half size: {db:.2} dB"); + } +} + #[test] fn prores_4444_with_alpha() { let Some(p) = prores_mov() else { return }; @@ -379,6 +432,97 @@ fn vp9_webm_frames() { } } +/// MXF OP1a as broadcast cameras write it: long-GOP MPEG-2 4:2:2 (XDCAM-like, B-frames) with +/// 24-bit PCM audio. +fn mpeg2_mxf() -> Option { + ffmpeg_fixture( + "testsrc2_mpeg2_pcm.mxf", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=1280x720:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mpeg2video", + "-pix_fmt", + "yuv422p", + "-q:v", + "2", + "-g", + "12", + "-bf", + "2", + "-c:a", + "pcm_s24le", + "-ac", + "2", + ], + ) +} + +/// MXF OP1a with intra-only DNxHD 36 (Avid's offline codec). +fn dnxhd_mxf() -> Option { + ffmpeg_fixture( + "testsrc2_dnxhd.mxf", + &["-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=25:duration=1", "-c:v", "dnxhd", "-b:v", "36M", "-pix_fmt", "yuv422p"], + ) +} + +/// MXF is importable footage (After Effects imports it), whatever the extension's case. +#[test] +fn mxf_is_importable() { + assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); + assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); + assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +} + +#[test] +fn mxf_mpeg2_with_pcm_audio() { + let Some(p) = mpeg2_mxf() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (1280, 720)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + eprintln!("mxf mpeg-2: codec {:?}", f.codec); + let pool = MediaPool::new(); + // sequential, a backwards seek, a jump into a GOP (B-frames reordered), the last frame + for n in [0usize, 1, 2, 3, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame(&p, n)); + eprintln!("mxf mpeg-2 frame {n}: {db:.2} dB"); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + // ffmpeg's sine source upmixed to stereo: −24 dBFS RMS + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +#[test] +fn mxf_dnxhd_frames() { + let Some(p) = dnxhd_mxf() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (1920, 1080)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!(!f.has_audio); + eprintln!("mxf dnxhd: codec {:?}", f.codec); + let pool = MediaPool::new(); + for n in [0usize, 12, 24, 3] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame(&p, n)); + eprintln!("mxf dnxhd frame {n}: {db:.2} dB"); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; @@ -454,6 +598,16 @@ fn png_with_alpha() { assert_eq!((th.width, th.height), (64, 32)); } +/// File ▸ Import of an FBX scene: importable by extension and probed as a 3D model item. +#[test] +fn fbx_imports_as_a_model() { + assert!(effectcraft_media::is_importable(Path::new("scene.fbx"))); + let fbx = "FBXHeaderExtension: {\n FBXVersion: 7400\n}\nObjects: {\n Geometry: 1, \"Geometry::Tri\", \"Mesh\" {\n Vertices: *9 {\n a: 0,0,0,1,0,0,0,1,0\n }\n PolygonVertexIndex: *3 {\n a: 0,1,-3\n }\n }\n Model: 2, \"Model::Tri\", \"Mesh\" {\n }\n}\nConnections: {\n C: \"OO\",2,0\n C: \"OO\",1,2\n}\n"; + let f = effectcraft_media::probe_bytes("scene.fbx", Arc::from(fbx.as_bytes())).expect("probe"); + assert_eq!(f.kind, FootageKind::Model); + assert_eq!(f.codec, "FBX (1 meshes, 1 triangles)"); +} + #[test] fn wav_audio() { let Some(p) = tone_wav() else { return }; @@ -569,6 +723,68 @@ fn exr_still_is_linear_float() { assert!(b[3] == 0.5 && b[0] > 0.85 && b[0] < 1.0, "{b:?}"); } +/// #745: a Targa file has no signature, so its format comes from the extension; it imports as +/// a still with straight alpha, identical to ffmpeg's own decode of the source. +#[test] +fn targa_still_matches_ffmpeg() { + let Some(png) = alpha_png() else { return }; + // ffmpeg's Targa encoder (run-length compressed) + let Some(tga) = ffmpeg_fixture("alpha.tga", &["-i", png.to_str().expect("utf8")]) else { return }; + assert!(effectcraft_media::is_importable(&tga)); + let f = probe(&tga).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height), (256, 128)); + assert_eq!(f.codec, "TGA"); + assert_eq!(f.alpha, AlphaMode::Straight); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + assert_eq!(*img, load_png(&png)); + // hostile input: a truncated file is refused or decodes partially, never panics + let bytes = std::fs::read(&tga).expect("read"); + for n in [0, 5, 18, 40, bytes.len() / 2] { + let cut: Arc<[u8]> = bytes.get(..n).expect("cut").into(); + if let Ok(f) = effectcraft_media::probe_bytes("cut.tga", cut.clone()) { + let pool = MediaPool::new(); + pool.add_bytes("cut.tga", cut); + let _ = pool.frame_at(&f, Tick::ZERO); + } + } +} + +/// #745: a Radiance HDR file holds linear float light, sRGB-encoded like OpenEXR (values above 1 +/// kept); ffmpeg's encoder is the oracle for the file layout. +#[test] +fn radiance_hdr_still_is_linear_float() { + // written with the image crate's encoder: linear 0.214 ≈ sRGB 0.5, and super-white 4.0 + let dir = fixtures(); + std::fs::create_dir_all(&dir).expect("mkdir"); + let p = dir.join(format!("float-{}.hdr", std::process::id())); + let px = vec![0.214f32, 0.214, 0.214, 4.0, 4.0, 4.0]; + image::DynamicImage::ImageRgb32F(image::ImageBuffer::from_raw(2, 1, px).expect("buf")).save(&p).expect("write hdr"); + assert!(effectcraft_media::is_importable(&p)); + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height), (2, 1)); + assert_eq!(f.codec, "HDR"); + assert_eq!(f.alpha, AlphaMode::Ignore); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + std::fs::remove_file(&p).ok(); + let a = img.data[0]; + assert!((a[0] - 0.5).abs() < 5e-3 && a[3] == 1.0, "{a:?}"); + assert!(img.data[1][0] > 1.5, "super-white kept: {:?}", img.data[1]); + + // ffmpeg's Radiance encoder (run-length compressed scanlines): a mid grey of 0.5 linear + let Some(ff) = ffmpeg_fixture("grey.hdr", &["-f", "lavfi", "-i", "color=c=0x808080:size=64x16", "-frames:v", "1", "-pix_fmt", "gbrpf32le"]) else { + return; + }; + let f = probe(&ff).expect("probe ffmpeg hdr"); + assert_eq!((f.width, f.height), (64, 16)); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame"); + let want = 1.055 * (128.0f32 / 255.0).powf(1.0 / 2.4) - 0.055; + for q in &img.data { + assert!((q[0] - want).abs() < 0.01 && (q[1] - want).abs() < 0.01 && (q[2] - want).abs() < 0.01 && q[3] == 1.0, "{q:?} vs {want}"); + } +} + /// A FLAC of `secs` seconds at `rate` Hz with `channels` channels of a 440 Hz sine (first two: /// −24 dBFS RMS, the rest quieter), samples of `fmt` (`s16`, or `s32` with 24 significant bits). fn sine_flac(secs: u32, rate: u32, channels: u32, fmt: &str) -> Option { @@ -615,9 +831,14 @@ fn flac_audio_of_any_shape_reads_in_device_blocks() { } at += 1024; } - // ffmpeg's sine source has an amplitude of 1/8, on both channels (mono is duplicated). + // ffmpeg's sine source has an amplitude of 1/8 (mono is duplicated). The fixture carries + // channels 0 and 1 at full level and the others at half, so a multichannel file sounds as + // its stereo downmix (tests/downmix.rs checks that matrix channel by channel). + let gains = effectcraft_media::downmix::stereo_gains(channels as usize, false); + let side = |s: usize| gains.iter().enumerate().map(|(c, g)| f64::from(g[s]) * if c < 2 { 1.0 } else { 0.5 }).sum::(); + let want = 0.125 * ((side(0).powi(2) + side(1).powi(2)) / 2.0).sqrt() / 2f64.sqrt(); let rms = (sum / n.max(1) as f64).sqrt(); - assert!((rms - 0.125 / 2f64.sqrt()).abs() < 0.003, "{}: rms {rms}", p.display()); + assert!((rms - want).abs() < 0.003, "{}: rms {rms}, expected {want}", p.display()); eprintln!("{}: read {secs} s in {:?}, slowest block after the first {worst:?}", p.display(), t0.elapsed()); assert_eq!(pool.stats().opened, 1, "opened once, not per block"); } @@ -674,3 +895,186 @@ fn conformed_flac_audio_reads_back_like_the_decoder() { assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded); let _ = std::fs::remove_dir_all(&folder); } + +/// File ▸ Import of an STL part: importable by extension and probed as a 3D model item. +#[test] +fn stl_imports_as_a_model() { + assert!(effectcraft_media::is_importable(Path::new("part.stl"))); + let ascii = "solid part\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid part\n"; + let f = effectcraft_media::probe_bytes("part.stl", Arc::from(ascii.as_bytes())).expect("probe"); + assert_eq!(f.kind, FootageKind::Model); + assert_eq!(f.codec, "STL (1 meshes, 1 triangles)"); +} + +/// `src` decoded by ffmpeg to 8-bit (`deep`: 16-bit) RGBA, premultiplied like the pool's frames. +fn ffmpeg_rgba(src: &Path, deep: bool) -> Vec<[f32; 4]> { + let out = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-i"]) + .arg(src) + .args(["-sws_flags", "accurate_rnd+full_chroma_int+bitexact", "-f", "rawvideo", "-pix_fmt", if deep { "rgba64le" } else { "rgba" }, "-"]) + .output() + .expect("run ffmpeg"); + assert!(out.status.success(), "ffmpeg decode of {}", src.display()); + let px = |v: [f32; 4]| [v[0] * v[3], v[1] * v[3], v[2] * v[3], v[3]]; + if !deep { + return out.stdout.as_chunks::<4>().0.iter().map(|p| px(p.map(|v| v as f32 / 255.0))).collect(); + } + out.stdout.as_chunks::<8>().0.iter().map(|p| px(std::array::from_fn(|i| u16::from_le_bytes([p[2 * i], p[2 * i + 1]]) as f32 / 65535.0))).collect() +} + +/// #756: DPX stills in every layout and depth ffmpeg's encoder writes (8-bit luma / RGB / RGBA / +/// ABGR, 10- and 12-bit filled RGB, 16-bit luma / RGB / RGBA, big- and little-endian, lines +/// padded to 32-bit words) import and decode as ffmpeg decodes them. +#[test] +fn dpx_stills_match_ffmpeg() { + let Some(png) = alpha_png() else { return }; + let src = png.to_str().expect("utf8"); + let formats = [ + ("rgb24", "DPX 8-bit RGB", false), + ("rgba", "DPX 8-bit RGBA", true), + ("abgr", "DPX 8-bit ABGR", true), + ("gray", "DPX 8-bit luma", false), + ("gray16be", "DPX 16-bit luma", false), + ("gbrp10be", "DPX 10-bit RGB", false), + ("gbrp10le", "DPX 10-bit RGB", false), + ("gbrp12be", "DPX 12-bit RGB", false), + ("gbrp12le", "DPX 12-bit RGB", false), + ("rgb48be", "DPX 16-bit RGB", false), + ("rgba64le", "DPX 16-bit RGBA", true), + ]; + for (pf, codec, alpha) in formats { + // 250 px wide: 8-bit and 10-bit lines need padding to a 32-bit word. + let Some(p) = ffmpeg_fixture(&format!("dpx-{pf}.dpx"), &["-i", src, "-vf", "scale=250:100", "-pix_fmt", pf]) else { return }; + assert!(effectcraft_media::is_importable(&p)); + let f = probe(&p).expect(pf); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height), (250, 100), "{pf}"); + // Some ffmpeg builds state the transfer (", linear"), which the codec name then carries. + assert!(f.codec == codec || f.codec.starts_with(&format!("{codec}, ")), "{pf}: {}", f.codec); + assert_eq!(f.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, "{pf}"); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect(pf); + let want = ffmpeg_rgba(&p, !codec.contains("8-bit")); + assert_eq!(want.len(), img.data.len(), "{pf}"); + let worst = img.data.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); + // (within one 16-bit step: ffmpeg widens 10/12-bit samples by bit replication) + assert!(worst < 2.0 / 65535.0, "{pf}: worst difference {worst}"); + } +} + +/// #756: a numbered run of DPX files plays as an image sequence. +#[test] +fn dpx_sequence_plays() { + let Some(first) = ffmpeg_fixture("dpxseq/plate.0001.dpx", &["-f", "lavfi", "-i", "color=c=0x202020:size=32x16", "-frames:v", "1", "-pix_fmt", "gbrp10le"]) + else { + return; + }; + let Some(second) = ffmpeg_fixture("dpxseq/plate.0002.dpx", &["-f", "lavfi", "-i", "color=c=0xe0e0e0:size=32x16", "-frames:v", "1", "-pix_fmt", "gbrp10le"]) + else { + return; + }; + let mut f = probe(&first).expect("probe"); + f.kind = FootageKind::Sequence; + f.sequence = [&first, &second].iter().map(|p| p.to_string_lossy().to_string()).collect(); + f.sync_sequence_duration(); + let pool = MediaPool::new(); + let level = |n| pool.frame_at(&f, t_of(f.frame_rate, n)).expect("frame").data[0][0]; + assert!((level(0) - 32.0 / 255.0).abs() < 0.01 && (level(1) - 224.0 / 255.0).abs() < 0.01, "{} {}", level(0), level(1)); +} + +/// #756: a Kodak Cineon file (no ffmpeg encoder: written here from the spec, 10-bit RGB +/// left-justified in big-endian 32-bit words; checked against ImageMagick's reader when written) +/// shows its log code values as stored; a truncated one is refused, never a panic. +#[test] +fn cineon_still_shows_code_values() { + let (w, h) = (5u32, 3u32); + let code = |x: u32, y: u32| [(x * 37 + y * 101) % 1024, (x * 11 + y * 7 + 300) % 1024, (x * 3 + y * 251 + 700) % 1024]; + let mut b = vec![0u8; 1024]; + b[..4].copy_from_slice(&[0x80, 0x2a, 0x5f, 0xd7]); + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); + b[8..12].copy_from_slice(&1024u32.to_be_bytes()); + b[24..28].copy_from_slice(b"V4.5"); + b[193] = 3; + for c in 0..3 { + let o = 196 + 28 * c; + b[o + 1] = c as u8 + 1; + b[o + 2] = 10; + b[o + 4..o + 8].copy_from_slice(&w.to_be_bytes()); + b[o + 8..o + 12].copy_from_slice(&h.to_be_bytes()); + } + b[681] = 5; + for y in 0..h { + for x in 0..w { + let [r, g, bl] = code(x, y); + b.extend_from_slice(&((r << 22) | (g << 12) | (bl << 2)).to_be_bytes()); + } + } + let bytes: Arc<[u8]> = b.clone().into(); + let f = effectcraft_media::probe_bytes("scan.cin", bytes.clone()).expect("probe"); + assert_eq!((f.kind, f.width, f.height, f.codec.as_str(), f.alpha), (FootageKind::Still, 5, 3, "Cineon 10-bit RGB, log", AlphaMode::Ignore)); + assert!(effectcraft_media::is_importable(Path::new("scan.cin"))); + let pool = MediaPool::new(); + pool.add_bytes("scan.cin", bytes); + let img = pool.frame_at(&f, Tick::ZERO).expect("frame"); + for y in 0..h { + for x in 0..w { + let want = code(x, y).map(|v| v as f32 / 1023.0); + let got = img.get(i64::from(x), i64::from(y)); + assert!((0..3).all(|k| (got[k] - want[k]).abs() < 1e-4) && got[3] == 1.0, "{x},{y}: {got:?} vs {want:?}"); + } + } + for n in [3, 100, 700, 1024, b.len() - 1] { + let cut: Arc<[u8]> = b.get(..n).expect("cut").into(); + assert!(effectcraft_media::probe_bytes("cut.cin", cut).is_err(), "{n}"); + } +} + +/// #329: a frame read that is called off (a scrub has moved on) gives up before the decoder +/// seeks and decodes forward from the keyframe, so it no longer holds the decoder the newest +/// frame waits for; the frame decodes as usual when it is wanted again. +#[test] +fn a_called_off_read_does_not_decode() { + use std::sync::atomic::AtomicBool; + let Some(p) = h264_mp4() else { return }; + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let t = t_of(f.frame_rate, 45); + let off = Arc::new(AtomicBool::new(true)); + let mut got = None; + pool.cancellable(&off, &mut || got = Some(pool.frame(ItemId(1), &f, t))); + assert!(got.expect("ran").is_none(), "a called-off read decoded its frame"); + assert_eq!(pool.stats().frames, 0, "nothing cached for it"); + let on = Arc::new(AtomicBool::new(false)); + let mut got = None; + pool.cancellable(&on, &mut || got = pool.frame(ItemId(1), &f, t)); + let img = got.expect("decoded once wanted again"); + assert_eq!((img.width, img.height), (1920, 1080)); +} + +/// H.264 High 4:2:2 10-bit: the software decoder lacks it; the pool says why, once, and the +/// footage reports it (not a black frame without a word). +#[test] +fn an_unsupported_stream_reports_why_it_shows_no_picture() { + let Some(p) = ffmpeg_fixture( + "h264_high422_10bit.mp4", + &["-f", "lavfi", "-i", "testsrc=size=320x180:rate=24:duration=1", "-c:v", "libx264", "-profile:v", "high422", "-pix_fmt", "yuv422p10le"], + ) else { + return; + }; + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + assert_eq!(pool.problem(&f), None, "nothing is wrong before a frame was asked for"); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // a decoder that handles 4:2:2 (FilmCraft's, or a hardware one): no notice + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("4:2:2"), "names the stream: {why}"); + assert!(why.contains("isn't supported"), "says it is unsupported: {why}"); + assert!(!why.contains("decode error"), "no error wrappers: {why}"); + // it stays for the other frames, and is dropped when the file is forgotten + assert!(pool.frame(ItemId(1), &f, Tick::from_seconds_f64(0.5)).is_none()); + assert_eq!(pool.problem(&f).as_deref(), Some(why.as_str())); + pool.forget(&f.path); + assert_eq!(pool.problem(&f), None); +} diff --git a/crates/media/tests/open_in_place.rs b/crates/media/tests/open_in_place.rs new file mode 100644 index 00000000..78b0abcb --- /dev/null +++ b/crates/media/tests/open_in_place.rs @@ -0,0 +1,101 @@ +//! Opening a long movie reads its index, not the whole file: probing it and decoding a frame and +//! some audio must not grow peak memory by anything near the file's size. +//! +//! The large movie is a small ffmpeg-made MP4 followed by a 256 MiB `free` box (a sparse file, so +//! it costs no disk writes). Reading it whole, as before, grew peak RSS by its size; read in place, +//! the box is skipped by its header. The only test in this binary, so peak RSS is its own. +//! Skips (passes with a note) without ffmpeg or off Linux (peak RSS from `/proc/self/status`). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use effectcraft_media::{MediaPool, probe}; +use effectcraft_time::Tick; + +const PADDING: u64 = 256 << 20; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media") +} + +/// A 2 s 320×240 H.264 + AAC movie (written to a temp name and renamed, so concurrent test +/// processes never see a partial file). `None` without ffmpeg. +fn small_mp4() -> Option { + let out = fixtures().join("in_place_small.mp4"); + if out.exists() { + return Some(out); + } + if !Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()) { + return None; + } + std::fs::create_dir_all(fixtures()).unwrap(); + let tmp = fixtures().join(format!("in_place_tmp_{}.mp4", std::process::id())); + let st = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=2"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2"]) + .args(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest"]) + .arg(&tmp) + .status() + .unwrap(); + assert!(st.success(), "ffmpeg failed"); + std::fs::rename(&tmp, &out).unwrap(); + Some(out) +} + +/// `/proc/self/status` field `key` in KiB. +fn status_kib(key: &str) -> Option { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let line = s.lines().find(|l| l.starts_with(key))?; + line.split_whitespace().nth(1)?.parse().ok() +} + +/// Peak RSS so far in bytes, after resetting it to the current RSS where the kernel allows that. +fn reset_peak() -> Option { + let _ = std::fs::write("/proc/self/clear_refs", "5"); + status_kib("VmHWM:").map(|k| k * 1024) +} + +#[test] +fn a_long_movie_is_read_in_place_not_whole() { + if !cfg!(target_os = "linux") { + eprintln!("peak RSS is read from /proc: skipping"); + return; + } + let Some(small) = small_mp4() else { + eprintln!("ffmpeg not found: skipping"); + return; + }; + // The same movie with 256 MiB of padding after its boxes. + let large = fixtures().join(format!("in_place_large_{}.mp4", std::process::id())); + std::fs::copy(&small, &large).unwrap(); + let mut f = std::fs::OpenOptions::new().append(true).open(&large).unwrap(); + f.write_all(&(8 + PADDING as u32).to_be_bytes()).unwrap(); + f.write_all(b"free").unwrap(); + f.set_len(std::fs::metadata(&small).unwrap().len() + 8 + PADDING).unwrap(); + drop(f); + + // Warm up on the small file (code paths, allocator, decoder tables). + let small_footage = probe(&small).unwrap(); + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let small_frame = pool.frame_at(&small_footage, Tick::ZERO).unwrap(); + let small_audio = pool.audio_samples(&small_footage, Tick::ZERO, 4800, 48_000); + + let before = reset_peak().unwrap(); + let footage = probe(&large).unwrap(); + let frame = pool.frame_at(&footage, Tick::ZERO).unwrap(); + let audio = pool.audio_samples(&footage, Tick::ZERO, 4800, 48_000); + let after = status_kib("VmHWM:").unwrap() * 1024; + drop(pool); + let _ = std::fs::remove_file(&large); + + let growth = after.saturating_sub(before); + eprintln!("peak RSS growth: {} KiB", growth >> 10); + assert!(growth < 64 << 20, "probing and decoding a {} MiB movie grew peak RSS by {} MiB", PADDING >> 20, growth >> 20); + // Same movie, same results. + assert_eq!(footage, effectcraft_project::Footage { path: footage.path.clone(), ..small_footage }); + assert_eq!(frame, small_frame); + assert_eq!(audio, small_audio); + assert!(audio.iter().any(|s| s.abs() > 0.1), "the tone decodes"); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 20a1fde0..25397921 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "effectcraft-model" -description = "3D models: glTF 2.0 (.gltf/.glb) and Wavefront OBJ/MTL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" +description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL, STL and FBX 7.x (binary and ASCII) import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes" version.workspace = true edition.workspace = true license.workspace = true @@ -15,3 +15,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } image = { workspace = true } +miniz_oxide = "0.8" diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 058be762..114606b9 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -203,9 +203,44 @@ fn rings(style: BevelStyle, bevel: f64, depth: f64) -> Vec<(f64, f64)> { out } +/// A surface of an extruded mesh, which After Effects colours and shades separately (the +/// Front / Bevel / Side / Back material groups). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Surface { + /// The front cap (facing the camera, on the layer plane). + Front, + /// The bevelled rings between the caps and the walls (front and back). + Bevel, + /// The straight walls. + Side, + /// The back cap. + Back, +} + +impl Surface { + pub const ALL: [Surface; 4] = [Surface::Front, Surface::Bevel, Surface::Side, Surface::Back]; + /// Index into [`Surface::ALL`] (and into the primitives of [`extrude_surfaces`]). + pub fn index(self) -> usize { + self as usize + } +} + /// Build the extruded mesh of outlines (one primitive, material 0). pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { - let mut prim = Primitive { material: Some(0), ..Default::default() }; + build(outlines, p, false).into_iter().next().unwrap_or_default() +} + +/// [`extrude`] split by surface: one primitive (material 0) per [`Surface`], in +/// [`Surface::ALL`] order. Together they hold the same triangles as [`extrude`]. +pub fn extrude_surfaces(outlines: &[Outline], p: &ExtrudeParams) -> [Primitive; 4] { + let mut v = build(outlines, p, true).into_iter(); + std::array::from_fn(|_| v.next().unwrap_or_default()) +} + +/// The extruded mesh in one primitive (`split` false) or one per [`Surface`]. +fn build(outlines: &[Outline], p: &ExtrudeParams, split: bool) -> Vec { + let mut prims = vec![Primitive { material: Some(0), ..Default::default() }; if split { 4 } else { 1 }]; + let slot = |s: Surface| if split { s.index() } else { 0 }; let depth = p.depth.max(1e-3); let push = |prim: &mut Primitive, pos: [f64; 3], n: [f64; 3]| -> u32 { prim.positions.push([pos[0] as f32, pos[1] as f32, pos[2] as f32]); @@ -271,10 +306,11 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { }; let (ni, nj) = (vn(i), vn(j)); let nrm = |xy: P, pr: (f64, f64)| [xy[0] * pr.0, xy[1] * pr.0, pr.1]; - let ai = push(&mut prim, ring_pos[ci][k][i], nrm(ni, pa)); - let aj = push(&mut prim, ring_pos[ci][k][j], nrm(nj, pa)); - let bj = push(&mut prim, ring_pos[ci][k + 1][j], nrm(nj, pb)); - let bi = push(&mut prim, ring_pos[ci][k + 1][i], nrm(ni, pb)); + let Some(prim) = prims.get_mut(slot(if is_wall { Surface::Side } else { Surface::Bevel })) else { continue }; + let ai = push(prim, ring_pos[ci][k][i], nrm(ni, pa)); + let aj = push(prim, ring_pos[ci][k][j], nrm(nj, pa)); + let bj = push(prim, ring_pos[ci][k + 1][j], nrm(nj, pb)); + let bi = push(prim, ring_pos[ci][k + 1][i], nrm(ni, pb)); prim.indices.extend([ai, aj, bj, ai, bj, bi]); } } @@ -286,10 +322,11 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { let tris = triangulate(&outer2, &holes2); let flat: Vec<(usize, usize)> = (0..cs.len()).flat_map(|ci| (0..cs[ci].pts.len()).map(move |i| (ci, i))).collect(); for (front, nz) in [(true, -1.0), (false, 1.0)] { + let Some(prim) = prims.get_mut(slot(if front { Surface::Front } else { Surface::Back })) else { continue }; let base = prim.positions.len() as u32; for &(ci, i) in &flat { let r = if front { 0 } else { ring_pos[ci].len() - 1 }; - push(&mut prim, ring_pos[ci][r][i], [0.0, 0.0, nz]); + push(prim, ring_pos[ci][r][i], [0.0, 0.0, nz]); } for t in &tris { let (a, b, c) = (base + t[0] as u32, base + t[1] as u32, base + t[2] as u32); @@ -297,7 +334,7 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive { } } } - prim + prims } /// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its @@ -309,9 +346,22 @@ pub const STACK_GAP: f64 = 0.02; /// `level × STACK_GAP` towards the camera (−z). pub fn extrude_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> Primitive { let mut prim = extrude(outlines, p); + stack(&mut prim, level); + prim +} + +/// [`extrude_surfaces`] at stacking level `level` (see [`extrude_stacked`]). +pub fn extrude_surfaces_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> [Primitive; 4] { + let mut prims = extrude_surfaces(outlines, p); + for prim in &mut prims { + stack(prim, level); + } + prims +} + +fn stack(prim: &mut Primitive, level: usize) { let dz = (level as f64 * STACK_GAP) as f32; for v in &mut prim.positions { v[2] -= dz; } - prim } diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs new file mode 100644 index 00000000..e2b9e1bd --- /dev/null +++ b/crates/model/src/fbx.rs @@ -0,0 +1,875 @@ +//! FBX 7.x scenes, binary and ASCII. +//! +//! Written from public descriptions of the format (the binary node-record layout, the ASCII +//! syntax, and the scene objects of the Autodesk FBX SDK documentation): no importer source was +//! read. +//! +//! - **Binary**: the `Kaydara FBX Binary` header and version, node records with 32-bit (before +//! 7.5) or 64-bit offsets, every property type (`Y C I F D L S R` and the `f d l i b` arrays, +//! zlib-compressed or not). Object names `Name\0\x01Class` become `Class::Name` as in ASCII. +//! - **ASCII**: `Key: value, value { children }` with `;` comments and `*N { a: … }` arrays. +//! - **Scene**: `Geometry` meshes (`Vertices`, `PolygonVertexIndex` polygons fan-triangulated, +//! the first `LayerElementNormal`, `LayerElementUV` and `LayerElementMaterial` with every +//! mapping and reference mode), `Model` nodes with the SDK transform (translation, rotation +//! offset and pivot, pre/post rotation, Euler rotation in its order, scaling offset and pivot, +//! scaling) and geometric transforms, `Material` (Lambert/Phong mapped onto +//! metallic-roughness), `Texture` and `Video` (embedded or relative image files), joined by +//! `Connections`. `GlobalSettings` axes turn the scene to +Y up. +//! +//! Not imported: FBX 6.x and older, animation stacks, skin deformers, blend shapes, cameras and +//! lights, NURBS and patches. Units stay the file's own (like OBJ). + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::obj::srgb_to_linear; +use crate::{AlphaMode, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, TexRef, Texture, perr, vec3}; + +/// The binary file's magic (`Kaydara FBX Binary`, two spaces, NUL, 0x1A, 0x00). +pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; + +/// Deepest node nesting accepted (real files nest well under 10 levels). +const MAX_DEPTH: usize = 64; +/// Largest decoded array accepted (bytes). +const MAX_ARRAY_BYTES: usize = 1 << 30; + +/// One property value of an FBX node. +#[derive(Clone, Debug, PartialEq)] +pub enum Prop { + Int(i64), + Float(f64), + Str(String), + Bytes(Vec), + Ints(Vec), + Floats(Vec), +} + +impl Prop { + fn as_f64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i as f64), + Prop::Float(f) => Some(*f), + _ => None, + } + } + fn as_i64(&self) -> Option { + match self { + Prop::Int(i) => Some(*i), + Prop::Float(f) if f.is_finite() => Some(*f as i64), + _ => None, + } + } + fn as_str(&self) -> Option<&str> { + match self { + Prop::Str(s) => Some(s), + _ => None, + } + } +} + +/// A node of the FBX document tree (the same for binary and ASCII files). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FbxNode { + pub name: String, + pub props: Vec, + pub children: Vec, +} + +impl FbxNode { + pub fn child(&self, name: &str) -> Option<&FbxNode> { + self.children.iter().find(|c| c.name == name) + } + pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.children.iter().filter(move |c| c.name == name) + } + fn prop(&self, i: usize) -> Option<&Prop> { + self.props.get(i) + } + fn str_at(&self, i: usize) -> Option<&str> { + self.prop(i).and_then(Prop::as_str) + } + /// The node's array as floats: a binary array property, an ASCII `a:` child, or the plain + /// value list. + pub fn floats(&self) -> Vec { + if let Some(a) = self.child("a") { + return a.floats(); + } + let mut out = vec![]; + for p in &self.props { + match p { + Prop::Floats(v) => out.extend_from_slice(v), + Prop::Ints(v) => out.extend(v.iter().map(|&i| i as f64)), + Prop::Int(i) => out.push(*i as f64), + Prop::Float(f) => out.push(*f), + _ => {} + } + } + out + } + /// The node's array as integers (see [`FbxNode::floats`]). + pub fn ints(&self) -> Vec { + if let Some(a) = self.child("a") { + return a.ints(); + } + let mut out = vec![]; + for p in &self.props { + match p { + Prop::Ints(v) => out.extend_from_slice(v), + Prop::Floats(v) => out.extend(v.iter().filter(|f| f.is_finite()).map(|&f| f as i64)), + p => out.extend(p.as_i64()), + } + } + out + } +} + +/// Read an FBX file (binary or ASCII) into its top-level nodes and the file version +/// (e.g. 7400). +pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } +} + +// --------------------------------------------------------------------------------------------- +// Binary + +struct Bin<'a> { + d: &'a [u8], + /// 64-bit record offsets (version 7500 and later). + wide: bool, +} + +impl Bin<'_> { + fn take(&self, pos: &mut usize, n: usize) -> Result<&[u8]> { + let end = pos.checked_add(n).ok_or_else(|| perr("FBX: offset overflow"))?; + let s = self.d.get(*pos..end).ok_or_else(|| perr(format!("FBX: truncated at byte {pos}")))?; + *pos = end; + Ok(s) + } + fn arr(&self, pos: &mut usize) -> Result<[u8; N]> { + let s = self.take(pos, N)?; + let mut a = [0u8; N]; + a.copy_from_slice(s); + Ok(a) + } + fn u32(&self, pos: &mut usize) -> Result { + Ok(u32::from_le_bytes(self.arr(pos)?)) + } + fn offset(&self, pos: &mut usize) -> Result { + if self.wide { Ok(u64::from_le_bytes(self.arr(pos)?)) } else { Ok(self.u32(pos)? as u64) } + } + fn to_usize(v: u64) -> Result { + usize::try_from(v).map_err(|_| perr("FBX: offset out of range")) + } + + /// Records until `end` or a null record. + fn list(&self, pos: &mut usize, end: usize, depth: usize) -> Result> { + if depth > MAX_DEPTH { + return Err(perr("FBX: nodes nested too deeply")); + } + let mut out = vec![]; + while *pos < end { + match self.record(pos, depth)? { + Some(n) => out.push(n), + None => break, + } + } + Ok(out) + } + + /// One node record; `None` for the null record that closes a list. + fn record(&self, pos: &mut usize, depth: usize) -> Result> { + let start = *pos; + let end = Self::to_usize(self.offset(pos)?)?; + let nprops = self.offset(pos)?; + let plen = Self::to_usize(self.offset(pos)?)?; + let nlen = self.arr::<1>(pos)?[0] as usize; + if end == 0 && nprops == 0 && plen == 0 && nlen == 0 { + return Ok(None); + } + if end <= start || end > self.d.len() { + return Err(perr(format!("FBX: bad record end {end} at byte {start}"))); + } + let name = String::from_utf8_lossy(self.take(pos, nlen)?).into_owned(); + let pstart = *pos; + let pend = pstart.checked_add(plen).filter(|&e| e <= end).ok_or_else(|| perr(format!("FBX: `{name}`: property list past the record")))?; + // Every property is at least one byte, so a count above the list length is corrupt. + if nprops > plen as u64 { + return Err(perr(format!("FBX: `{name}`: {nprops} properties in {plen} bytes"))); + } + let mut props = Vec::with_capacity(nprops as usize); + for _ in 0..nprops { + props.push(self.prop(pos, pend)?); + } + *pos = pend; + let children = if *pos < end { self.list(pos, end, depth + 1)? } else { vec![] }; + *pos = end; + Ok(Some(FbxNode { name, props, children })) + } + + fn prop(&self, pos: &mut usize, pend: usize) -> Result { + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let t = sub.arr::<1>(pos)?[0]; + Ok(match t { + b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), + b'C' => Prop::Int((sub.arr::<1>(pos)?[0] != 0) as i64), + b'I' => Prop::Int(i32::from_le_bytes(sub.arr(pos)?) as i64), + b'L' => Prop::Int(i64::from_le_bytes(sub.arr(pos)?)), + b'F' => Prop::Float(f32::from_le_bytes(sub.arr(pos)?) as f64), + b'D' => Prop::Float(f64::from_le_bytes(sub.arr(pos)?)), + b'S' => { + let n = sub.u32(pos)? as usize; + Prop::Str(object_name(sub.take(pos, n)?)) + } + b'R' => { + let n = sub.u32(pos)? as usize; + Prop::Bytes(sub.take(pos, n)?.to_vec()) + } + b'f' | b'd' | b'l' | b'i' | b'b' => { + let len = sub.u32(pos)? as usize; + let enc = sub.u32(pos)?; + let clen = sub.u32(pos)? as usize; + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + let data = sub.take(pos, clen)?; + let raw = match enc { + 0 => data.to_vec(), + 1 => miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(data, want).map_err(|e| perr(format!("FBX: array zlib: {e:?}")))?, + e => return Err(perr(format!("FBX: unknown array encoding {e}"))), + }; + let raw = raw.get(..want).ok_or_else(|| perr("FBX: array shorter than its length"))?; + match t { + b'f' => Prop::Floats(raw.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c) as f64).collect()), + b'd' => Prop::Floats(raw.as_chunks::<8>().0.iter().map(|c| f64::from_le_bytes(*c)).collect()), + b'l' => Prop::Ints(raw.as_chunks::<8>().0.iter().map(|c| i64::from_le_bytes(*c)).collect()), + b'i' => Prop::Ints(raw.as_chunks::<4>().0.iter().map(|c| i32::from_le_bytes(*c) as i64).collect()), + _ => Prop::Ints(raw.iter().map(|&b| (b != 0) as i64).collect()), + } + } + t => return Err(perr(format!("FBX: unknown property type 0x{t:02x}"))), + }) + } +} + +/// Binary object names are `Name\0\x01Class`; ASCII writes `Class::Name`. +fn object_name(b: &[u8]) -> String { + let s = String::from_utf8_lossy(b); + match s.split_once("\0\x01") { + Some((name, class)) => format!("{class}::{name}"), + None => s.into_owned(), + } +} + +fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { + let mut pos = BINARY_MAGIC.len(); + let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500 }; + let mut nodes = vec![]; + while pos < bytes.len() { + match bin.record(&mut pos, 0) { + Ok(Some(n)) => nodes.push(n), + Ok(None) => break, + // A damaged tail (footer, padding) after complete sections is ignored. + Err(_) if nodes.iter().any(|n| n.name == "Objects") => break, + Err(e) => return Err(e), + } + } + Ok((version, nodes)) +} + +// --------------------------------------------------------------------------------------------- +// ASCII + +#[derive(Clone, Debug, PartialEq)] +enum Tok { + Key(String), + Val(Prop), + /// `*N` array length (the values follow in an `a:` child). + Count, + Comma, + Open, + Close, +} + +fn tokenize(text: &str) -> Vec { + let mut out = vec![]; + let mut it = text.char_indices().peekable(); + while let Some(&(i, c)) = it.peek() { + match c { + c if c.is_whitespace() => { + it.next(); + } + ';' => { + for (_, c) in it.by_ref() { + if c == '\n' { + break; + } + } + } + ',' => { + it.next(); + out.push(Tok::Comma); + } + '{' => { + it.next(); + out.push(Tok::Open); + } + '}' => { + it.next(); + out.push(Tok::Close); + } + '"' => { + it.next(); + let start = i + 1; + let mut end = text.len(); + for (j, c) in it.by_ref() { + if c == '"' { + end = j; + break; + } + } + out.push(Tok::Val(Prop::Str(text.get(start..end).unwrap_or("").replace(""", "\"")))); + } + _ => { + let start = i; + let mut end = text.len(); + while let Some(&(j, c)) = it.peek() { + if c.is_whitespace() || matches!(c, ',' | '{' | '}' | '"' | ';' | ':') { + end = j; + break; + } + it.next(); + } + let word = text.get(start..end).unwrap_or(""); + if it.peek().is_some_and(|&(_, c)| c == ':') { + it.next(); + out.push(Tok::Key(word.to_string())); + } else if word.starts_with('*') { + out.push(Tok::Count); + } else if let Ok(i) = word.parse::() { + out.push(Tok::Val(Prop::Int(i))); + } else if let Ok(f) = word.parse::() { + out.push(Tok::Val(Prop::Float(f))); + } else if !word.is_empty() { + out.push(Tok::Val(Prop::Str(word.to_string()))); + } else { + // A stray `:` with no key. + it.next(); + } + } + } + } + out +} + +fn ascii_list(toks: &[Tok], i: &mut usize, depth: usize) -> Result> { + if depth > MAX_DEPTH { + return Err(perr("FBX: nodes nested too deeply")); + } + let mut out = vec![]; + while let Some(t) = toks.get(*i) { + *i += 1; + match t { + Tok::Close if depth > 0 => return Ok(out), + Tok::Key(name) => { + let mut node = FbxNode { name: name.clone(), ..FbxNode::default() }; + loop { + match toks.get(*i) { + Some(Tok::Val(v)) => node.props.push(v.clone()), + Some(Tok::Count | Tok::Comma) => {} + _ => break, + } + *i += 1; + } + if toks.get(*i) == Some(&Tok::Open) { + *i += 1; + node.children = ascii_list(toks, i, depth + 1)?; + } + out.push(node); + } + _ => {} + } + } + Ok(out) +} + +fn read_ascii(text: &str) -> Result<(u32, Vec)> { + let toks = tokenize(text); + let mut i = 0; + let nodes = ascii_list(&toks, &mut i, 0)?; + let version = nodes + .iter() + .find(|n| n.name == "FBXHeaderExtension") + .and_then(|h| h.child("FBXVersion")) + .and_then(|v| v.prop(0)) + .and_then(Prop::as_i64) + .and_then(|v| u32::try_from(v).ok()) + .unwrap_or(0); + Ok((version, nodes)) +} + +// --------------------------------------------------------------------------------------------- +// Scene + +/// `Properties70` entries by name (`P: name, type, label, flags, values…`). +struct Props70<'a>(HashMap<&'a str, &'a FbxNode>); + +impl<'a> Props70<'a> { + fn of(node: &'a FbxNode) -> Props70<'a> { + let mut m = HashMap::new(); + if let Some(p) = node.child("Properties70") { + for e in p.children_named("P") { + if let Some(name) = e.str_at(0) { + m.entry(name).or_insert(e); + } + } + } + Props70(m) + } + fn values(&self, name: &str) -> Vec { + self.0.get(name).map(|e| e.props.iter().skip(4).filter_map(Prop::as_f64).collect()).unwrap_or_default() + } + fn f(&self, name: &str) -> Option { + self.values(name).first().copied().filter(|v| v.is_finite()) + } + fn v3(&self, name: &str, default: [f64; 3]) -> [f64; 3] { + match self.values(name).as_slice() { + [x, y, z, ..] if x.is_finite() && y.is_finite() && z.is_finite() => [*x, *y, *z], + _ => default, + } + } +} + +/// An Euler rotation (degrees) in FBX rotation order (`eEulerXYZ` = 0 rotates about X first). +fn euler(d: [f64; 3], order: i64) -> Mat4 { + let (x, y, z) = (Mat4::rotate_x(d[0]), Mat4::rotate_y(d[1]), Mat4::rotate_z(d[2])); + match order { + 1 => y * z * x, + 2 => x * z * y, + 3 => z * x * y, + 4 => y * x * z, + 5 => x * y * z, + _ => z * y * x, + } +} + +fn translate(t: [f64; 3]) -> Mat4 { + Mat4::translate(vec3(t[0], t[1], t[2])) +} + +/// A Model's local transform: `T · Roff · Rp · Rpre · R · Rpost⁻¹ · Rp⁻¹ · Soff · Sp · S · Sp⁻¹`. +fn model_local(p: &Props70) -> Mat4 { + let order = p.f("RotationOrder").map_or(0, |o| o as i64); + let active = p.f("RotationActive").is_some_and(|a| a != 0.0); + let (pre, post) = + if active { (euler(p.v3("PreRotation", [0.0; 3]), 0), euler(p.v3("PostRotation", [0.0; 3]), 0).transpose()) } else { (Mat4::IDENTITY, Mat4::IDENTITY) }; + let rp = p.v3("RotationPivot", [0.0; 3]); + let sp = p.v3("ScalingPivot", [0.0; 3]); + let s = p.v3("Lcl Scaling", [1.0; 3]); + translate(p.v3("Lcl Translation", [0.0; 3])) + * translate(p.v3("RotationOffset", [0.0; 3])) + * translate(rp) + * pre + * euler(p.v3("Lcl Rotation", [0.0; 3]), order) + * post + * translate([-rp[0], -rp[1], -rp[2]]) + * translate(p.v3("ScalingOffset", [0.0; 3])) + * translate(sp) + * Mat4::scale(vec3(s[0], s[1], s[2])) + * translate([-sp[0], -sp[1], -sp[2]]) +} + +/// The geometric transform of a Model (applies to its geometry, not to its children). +fn geometric(p: &Props70) -> Option { + let t = p.v3("GeometricTranslation", [0.0; 3]); + let r = p.v3("GeometricRotation", [0.0; 3]); + let s = p.v3("GeometricScaling", [1.0; 3]); + if t == [0.0; 3] && r == [0.0; 3] && s == [1.0; 3] { + return None; + } + Some(translate(t) * euler(r, 0) * Mat4::scale(vec3(s[0], s[1], s[2]))) +} + +/// The file's axis system → +Y up, +Z front, +X right. +fn axis_matrix(settings: Option<&FbxNode>) -> Mat4 { + let p = settings.map(Props70::of); + let get = |name: &str, d: f64| p.as_ref().and_then(|p| p.f(name)).unwrap_or(d); + let axis = |a: f64, s: f64| -> Option<[f64; 4]> { + let a = a as i64; + let sign = if s < 0.0 { -1.0 } else { 1.0 }; + let mut v = [0.0; 4]; + *v.get_mut(usize::try_from(a).ok().filter(|&a| a < 3)?)? = sign; + Some(v) + }; + let (Some(right), Some(up), Some(front)) = ( + axis(get("CoordAxis", 0.0), get("CoordAxisSign", 1.0)), + axis(get("UpAxis", 1.0), get("UpAxisSign", 1.0)), + axis(get("FrontAxis", 2.0), get("FrontAxisSign", 1.0)), + ) else { + return Mat4::IDENTITY; + }; + let m = Mat4([right, up, front, [0.0, 0.0, 0.0, 1.0]]); + // Two axes on the same file axis: not a rotation. + let used = |k: usize| right[k] != 0.0 || up[k] != 0.0 || front[k] != 0.0; + if (0..3).all(used) { m } else { Mat4::IDENTITY } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mapping { + PolygonVertex, + Vertex, + Polygon, + AllSame, +} + +/// A geometry layer element (normals, UVs, material indices). +struct LayerData { + mapping: Mapping, + data: Vec, + index: Option>, + width: usize, +} + +impl LayerData { + fn of(geom: &FbxNode, element: &str, data: &str, index: &str, width: usize) -> Option { + let e = geom.child(element)?; + let mapping = match e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("ByPolygonVertex") { + "ByPolygonVertex" => Mapping::PolygonVertex, + "ByVertex" | "ByVertice" | "ByControlPoint" => Mapping::Vertex, + "ByPolygon" => Mapping::Polygon, + "AllSame" => Mapping::AllSame, + _ => return None, + }; + let reference = e.child("ReferenceInformationType").and_then(|m| m.str_at(0)).unwrap_or("Direct"); + let index = (reference != "Direct").then(|| e.child(index).map(FbxNode::ints)).flatten(); + Some(LayerData { mapping, data: e.child(data)?.floats(), index, width }) + } + fn get(&self, pv: usize, v: usize, poly: usize) -> Option<&[f64]> { + let i = match self.mapping { + Mapping::PolygonVertex => pv, + Mapping::Vertex => v, + Mapping::Polygon => poly, + Mapping::AllSame => 0, + }; + let j = match &self.index { + Some(ix) => usize::try_from(*ix.get(i)?).ok()?, + None => i, + }; + let a = j.checked_mul(self.width)?; + self.data.get(a..a.checked_add(self.width)?) + } +} + +fn f32s(s: &[f64]) -> [f32; N] { + let mut a = [0.0f32; N]; + for (o, v) in a.iter_mut().zip(s) { + *o = if v.is_finite() { *v as f32 } else { 0.0 }; + } + a +} + +/// A Mesh geometry with its polygons triangulated; `materials[k]` is the model-level material +/// index of the geometry's material slot `k`. +fn build_mesh(name: &str, geom: &FbxNode, materials: &[usize], with_normal_maps: &dyn Fn(usize) -> bool) -> Option { + let positions: Vec<[f32; 3]> = geom.child("Vertices")?.floats().as_chunks::<3>().0.iter().map(|c| f32s::<3>(c)).collect(); + let pvi = geom.child("PolygonVertexIndex")?.ints(); + let normals = LayerData::of(geom, "LayerElementNormal", "Normals", "NormalsIndex", 3); + let uvs = LayerData::of(geom, "LayerElementUV", "UV", "UVIndex", 2); + let mats = geom.child("LayerElementMaterial").and_then(|e| { + let mapping = e.child("MappingInformationType").and_then(|m| m.str_at(0)).unwrap_or("AllSame"); + let ids = e.child("Materials")?.ints(); + Some((mapping == "ByPolygon", ids)) + }); + + type Key = (u32, [u32; 3], [u32; 2]); + let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut corners: Vec<(usize, usize)> = vec![]; + let mut poly = 0usize; + for (pv, &raw) in pvi.iter().enumerate() { + // The last corner of a polygon is stored as `-index - 1`. + let (v, last) = if raw < 0 { (!raw, true) } else { (raw, false) }; + corners.push((pv, usize::try_from(v).unwrap_or(usize::MAX))); + if !last { + continue; + } + let poly_corners = std::mem::take(&mut corners); + let p_index = poly; + poly += 1; + if poly_corners.len() < 3 || poly_corners.iter().any(|&(_, v)| v >= positions.len()) { + continue; + } + let slot = mats.as_ref().and_then(|(by_poly, ids)| { + let k = if *by_poly { *ids.get(p_index)? } else { *ids.first()? }; + materials.get(usize::try_from(k).ok()?).copied() + }); + let slot = slot.or_else(|| materials.first().copied()); + let at = match prims.iter().position(|p| p.0 == slot) { + Some(i) => i, + None => { + prims.push((slot, Primitive { material: slot, ..Default::default() }, HashMap::new(), vec![])); + prims.len() - 1 + } + }; + let Some((_, prim, map, need)) = prims.get_mut(at) else { continue }; + let mut ids = Vec::with_capacity(poly_corners.len()); + for &(pv, v) in &poly_corners { + let n = normals.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<3>); + let t = uvs.as_ref().and_then(|l| l.get(pv, v, p_index)).map(f32s::<2>); + let key: Key = (v as u32, n.unwrap_or([f32::NAN; 3]).map(f32::to_bits), t.unwrap_or([f32::NAN; 2]).map(f32::to_bits)); + let id = *map.entry(key).or_insert_with(|| { + prim.positions.push(positions.get(v).copied().unwrap_or_default()); + prim.normals.push(n.unwrap_or_default()); + prim.uvs.push(t.unwrap_or_default()); + need.push(n.is_none()); + (prim.positions.len() - 1) as u32 + }); + ids.push(id); + } + for i in 2..ids.len() { + prim.indices.extend([ids[0], ids[i - 1], ids[i]]); + } + } + let mut mesh = Mesh { name: name.to_string(), primitives: vec![] }; + for (_, mut p, _, need) in prims { + if p.indices.is_empty() { + continue; + } + if need.iter().any(|&b| b) { + let given = p.normals.clone(); + p.compute_normals(); + for ((n, g), need) in p.normals.iter_mut().zip(given).zip(&need) { + if !need { + *n = g; + } + } + } + if uvs.is_none() { + p.uvs.clear(); + } else { + // FBX's v axis points up the image; textures are stored top row first. + for uv in p.uvs.iter_mut() { + uv[1] = 1.0 - uv[1]; + } + } + if p.material.is_some_and(with_normal_maps) { + p.compute_tangents(); + } + mesh.primitives.push(p); + } + (!mesh.primitives.is_empty()).then_some(mesh) +} + +/// Lambert/Phong surface properties onto metallic-roughness. +fn material(name: &str, node: &FbxNode) -> Material { + let p = Props70::of(node); + let phong = node.child("ShadingModel").and_then(|s| s.str_at(0)).is_some_and(|s| s.eq_ignore_ascii_case("phong")); + let factor = p.f("DiffuseFactor").unwrap_or(1.0); + let d = p.v3("DiffuseColor", p.v3("Diffuse", [0.8; 3])); + let alpha = match p.f("Opacity") { + Some(o) => o, + None => { + let tc = p.v3("TransparentColor", [0.0; 3]); + 1.0 - (tc[0] + tc[1] + tc[2]) / 3.0 * p.f("TransparencyFactor").unwrap_or(0.0) + } + } + .clamp(0.0, 1.0); + let lin = |c: f64| srgb_to_linear((c * factor).clamp(0.0, 1.0) as f32); + let e = p.v3("EmissiveColor", [0.0; 3]); + let ef = p.f("EmissiveFactor").unwrap_or(1.0); + let roughness = if phong { + let ns = p.f("ShininessExponent").or_else(|| p.f("Shininess")).unwrap_or(20.0).max(0.0); + (2.0 / (ns + 2.0)).sqrt() as f32 + } else { + 1.0 + }; + Material { + name: name.to_string(), + base_color: [lin(d[0]), lin(d[1]), lin(d[2]), alpha as f32], + metallic: 0.0, + roughness, + emissive: e.map(|c| srgb_to_linear((c * ef).clamp(0.0, 1.0) as f32)), + alpha_mode: if alpha < 0.999 { AlphaMode::Blend } else { AlphaMode::Opaque }, + ..Material::default() + } +} + +/// Decode standard base64 (ASCII FBX embeds `Video` content this way); `None` on bad input. +fn base64(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + c if c.is_ascii_whitespace() => continue, + _ => return None, + }; + acc = (acc << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +fn short_name(full: &str) -> &str { + full.split_once("::").map_or(full, |(_, n)| n) +} + +/// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. +pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { + let (version, doc) = read(bytes)?; + if version != 0 && version < 7000 { + return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); + } + let top = |name: &str| doc.iter().find(|n| n.name == name); + let objects = top("Objects").ok_or_else(|| perr("FBX: no Objects section"))?; + let by_id: HashMap = objects.children.iter().filter_map(|o| Some((o.prop(0)?.as_i64()?, o))).collect(); + let kind = |id: i64| by_id.get(&id).map(|n| n.name.as_str()); + + // Connections: (child, parent, property) in file order. + let mut links: Vec<(i64, i64, Option<&str>)> = vec![]; + if let Some(c) = top("Connections") { + for e in c.children_named("C") { + let (Some(a), Some(b)) = (e.prop(1).and_then(Prop::as_i64), e.prop(2).and_then(Prop::as_i64)) else { continue }; + links.push((a, b, e.str_at(3))); + } + } + + let model_ids: Vec = objects.children.iter().filter(|o| o.name == "Model").filter_map(|o| o.prop(0)?.as_i64()).collect(); + let mut node_of: HashMap = HashMap::new(); + let mut nodes = vec![Node { name: "FBX axes".into(), matrix: Some(axis_matrix(top("GlobalSettings"))), ..Node::default() }]; + for &id in &model_ids { + if node_of.contains_key(&id) { + continue; + } + let Some(o) = by_id.get(&id) else { continue }; + let name = short_name(o.str_at(1).unwrap_or("")).to_string(); + node_of.insert(id, nodes.len()); + nodes.push(Node { name, matrix: Some(model_local(&Props70::of(o))), ..Node::default() }); + } + + // Hierarchy: the first Model→Model link is the parent; anything else hangs off the root. + let mut parent: HashMap = HashMap::new(); + for &(c, p, _) in &links { + if let (Some(&ci), Some(&pi)) = (node_of.get(&c), node_of.get(&p)) + && ci != pi + { + parent.entry(ci).or_insert(pi); + } + } + for i in 1..nodes.len() { + let p = parent.get(&i).copied().unwrap_or(0); + if let Some(n) = nodes.get_mut(p) { + n.children.push(i); + } + } + + // Materials (in first-use order) and their textures. + let mut materials: Vec = vec![]; + let mut textures: Vec> = vec![]; + let mut material_of: HashMap = HashMap::new(); + let mut texture_of: HashMap> = HashMap::new(); + let mut load_texture = |tid: i64, textures: &mut Vec>| -> Option { + if let Some(t) = texture_of.get(&tid) { + return *t; + } + let t = (|| { + let tex = by_id.get(&tid)?; + let embedded = links.iter().filter(|l| l.1 == tid && kind(l.0) == Some("Video")).find_map(|l| { + let content = by_id.get(&l.0)?.child("Content")?; + match content.props.first()? { + Prop::Bytes(b) if !b.is_empty() => Some(b.clone()), + Prop::Str(_) => { + let s: String = content.props.iter().filter_map(Prop::as_str).collect(); + base64(&s).filter(|b| !b.is_empty()) + } + _ => None, + } + }); + let bytes = embedded.or_else(|| { + ["RelativeFilename", "FileName"].iter().find_map(|k| { + let f = tex.child(k)?.str_at(0)?.replace('\\', "/"); + resolve(&f).or_else(|| resolve(f.rsplit('/').next()?)) + }) + })?; + let t = Texture::decode(&bytes).ok()?; + textures.push(Arc::new(t)); + Some(textures.len() - 1) + })(); + texture_of.insert(tid, t); + t + }; + let mut material_index = |mid: i64, materials: &mut Vec, textures: &mut Vec>| -> Option { + if let Some(&m) = material_of.get(&mid) { + return Some(m); + } + let node = by_id.get(&mid)?; + let mut m = material(short_name(node.str_at(1).unwrap_or("")), node); + for &(tid, _, prop) in links.iter().filter(|l| l.1 == mid && kind(l.0) == Some("Texture")) { + let slot = match prop { + Some("DiffuseColor") => &mut m.base_color_tex, + Some("NormalMap") => &mut m.normal_tex, + Some("EmissiveColor") => &mut m.emissive_tex, + _ => continue, + }; + if slot.is_none() { + *slot = load_texture(tid, textures).map(|texture| TexRef { texture, uv_set: 0 }); + } + } + if m.emissive_tex.is_some() && m.emissive == [0.0; 3] { + m.emissive = [1.0; 3]; + } + materials.push(m); + material_of.insert(mid, materials.len() - 1); + Some(materials.len() - 1) + }; + + // One mesh per Model with Mesh geometry (material slots are per Model). + let mut meshes: Vec = vec![]; + for &id in &model_ids { + let Some(&ni) = node_of.get(&id) else { continue }; + let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; + let Some(geom) = by_id.get(&gid) else { continue }; + let mats: Vec = + links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + meshes.push(mesh); + let mesh_index = meshes.len() - 1; + let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); + match geo { + Some(matrix) => { + nodes.push(Node { name: format!("{name} geometry"), matrix: Some(matrix), mesh: Some(mesh_index), ..Node::default() }); + let gi = nodes.len() - 1; + if let Some(n) = nodes.get_mut(ni) { + n.children.push(gi); + } + } + None => { + if let Some(n) = nodes.get_mut(ni) { + n.mesh = Some(mesh_index); + } + } + } + } + if meshes.is_empty() { + return Err(perr("FBX file without meshes")); + } + Ok(Model { meshes, nodes, roots: vec![0], materials, textures, ..Model::default() }) +} + +#[cfg(test)] +#[path = "fbx_tests.rs"] +mod tests; diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs new file mode 100644 index 00000000..3cd8ba5a --- /dev/null +++ b/crates/model/src/fbx_tests.rs @@ -0,0 +1,327 @@ +use super::*; +use crate::load; + +/// A Z-up scene: a Null parent with a mesh child (rotated, scaled, with a geometric offset) +/// holding a quad and a triangle with a material each. +pub(crate) const ASCII: &str = r#"; FBX 7.4.0 project file +; ---------------------------------------------------- +FBXHeaderExtension: { + FBXHeaderVersion: 1003 + FBXVersion: 7400 + Creator: "EffectCraft test" +} +GlobalSettings: { + Version: 1000 + Properties70: { + P: "UpAxis", "int", "Integer", "",2 + P: "UpAxisSign", "int", "Integer", "",1 + P: "FrontAxis", "int", "Integer", "",1 + P: "FrontAxisSign", "int", "Integer", "",-1 + P: "CoordAxis", "int", "Integer", "",0 + P: "CoordAxisSign", "int", "Integer", "",1 + } +} +Objects: { + Geometry: 100, "Geometry::Shape", "Mesh" { + Vertices: *15 { + a: 0,0,0,1,0,0,1,1,0,0,1,0,0.5,0.5,1 + } + PolygonVertexIndex: *7 { + a: 0,1,2,-4,0,1,-5 + } + LayerElementNormal: 0 { + MappingInformationType: "ByPolygon" + ReferenceInformationType: "Direct" + Normals: *6 { + a: 0,0,1,0,-1,0 + } + } + LayerElementUV: 0 { + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "IndexToDirect" + UV: *8 { + a: 0,0,1,0,1,1,0,1 + } + UVIndex: *7 { + a: 0,1,2,3,0,1,2 + } + } + LayerElementMaterial: 0 { + MappingInformationType: "ByPolygon" + ReferenceInformationType: "IndexToDirect" + Materials: *2 { + a: 0,1 + } + } + } + Model: 200, "Model::Parent", "Null" { + Properties70: { + P: "Lcl Translation", "Lcl Translation", "", "A",10,0,0 + } + } + Model: 300, "Model::Shape", "Mesh" { + Properties70: { + P: "Lcl Rotation", "Lcl Rotation", "", "A",0,0,90 + P: "Lcl Scaling", "Lcl Scaling", "", "A",2,2,2 + P: "GeometricTranslation", "Vector3D", "Vector", "",0,0,1 + } + Shading: T + } + Material: 400, "Material::Red", "" { + ShadingModel: "phong" + Properties70: { + P: "DiffuseColor", "Color", "", "A",1,0,0 + P: "ShininessExponent", "Number", "", "A",98 + } + } + Material: 500, "Material::Glass", "" { + ShadingModel: "lambert" + Properties70: { + P: "DiffuseColor", "Color", "", "A",0,0,1 + P: "Opacity", "double", "Number", "",0.25 + } + } +} +Connections: { + ;Model::Parent, Model::RootNode + C: "OO",200,0 + C: "OO",300,200 + C: "OO",100,300 + C: "OO",400,300 + C: "OO",500,300 +} +"#; + +fn check_scene(m: &Model) { + assert_eq!(m.meshes.len(), 1); + let names: Vec<&str> = m.nodes.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["FBX axes", "Parent", "Shape", "Shape geometry"]); + assert_eq!(m.roots, vec![0]); + assert_eq!(m.nodes[0].children, vec![1]); + assert_eq!(m.nodes[1].children, vec![2]); + assert_eq!(m.nodes[2].children, vec![3]); + assert_eq!(m.nodes[3].mesh, Some(0)); + + let prims = &m.meshes[0].primitives; + assert_eq!(prims.len(), 2); + let (quad, tri) = (&prims[0], &prims[1]); + assert_eq!((quad.positions.len(), quad.indices.clone()), (4, vec![0, 1, 2, 0, 2, 3])); + assert_eq!((tri.positions.len(), tri.triangle_count()), (3, 1)); + assert_eq!(quad.normals, vec![[0.0, 0.0, 1.0]; 4]); + assert_eq!(tri.normals, vec![[0.0, -1.0, 0.0]; 3]); + // UVs through the index, v flipped to image rows. + assert_eq!(quad.uvs, vec![[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]); + + assert_eq!(m.materials.len(), 2); + let (red, glass) = (&m.materials[quad.material.unwrap()], &m.materials[tri.material.unwrap()]); + assert_eq!(red.name, "Red"); + assert_eq!(red.base_color, [1.0, 0.0, 0.0, 1.0]); + assert!((red.roughness - 0.02f32.sqrt()).abs() < 1e-6 && red.metallic == 0.0); + assert_eq!(glass.name, "Glass"); + assert_eq!(glass.base_color, [0.0, 0.0, 1.0, 0.25]); + assert_eq!((glass.alpha_mode, glass.roughness), (AlphaMode::Blend, 1.0)); + + // Z-up → Y-up, parent translation, Z rotation, scale and the geometric offset. + let inst = m.instances(None, 0.0); + assert_eq!(inst.len(), 1); + let apex = inst[0].world.apply(vec3(0.5, 0.5, 1.0)); + assert!((apex.x - 9.0).abs() < 1e-9 && (apex.y - 4.0).abs() < 1e-9 && (apex.z + 1.0).abs() < 1e-9, "{apex:?}"); + let (lo, hi) = m.bounds().unwrap(); + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(lo, [8.0, 2.0, -2.0]) && close(hi, [10.0, 4.0, 0.0]), "{lo:?} {hi:?}"); +} + +/// A binary FBX of the same node tree (`a:` children become typed arrays). +fn write_binary(nodes: &[FbxNode], version: u32, compress: bool) -> Vec { + fn prop(out: &mut Vec, p: &Prop, compress: bool) { + let array = |out: &mut Vec, t: u8, len: usize, raw: Vec| { + out.push(t); + out.extend((len as u32).to_le_bytes()); + let data = if compress { miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6) } else { raw }; + out.extend((compress as u32).to_le_bytes()); + out.extend((data.len() as u32).to_le_bytes()); + out.extend(data); + }; + match p { + Prop::Int(i) => match i32::try_from(*i) { + Ok(v) => { + out.push(b'I'); + out.extend(v.to_le_bytes()); + } + Err(_) => { + out.push(b'L'); + out.extend(i.to_le_bytes()); + } + }, + Prop::Float(f) => { + out.push(b'D'); + out.extend(f.to_le_bytes()); + } + Prop::Str(s) => { + let s = match s.split_once("::") { + Some((class, name)) => format!("{name}\0\x01{class}"), + None => s.clone(), + }; + out.push(b'S'); + out.extend((s.len() as u32).to_le_bytes()); + out.extend(s.as_bytes()); + } + Prop::Bytes(b) => { + out.push(b'R'); + out.extend((b.len() as u32).to_le_bytes()); + out.extend(b); + } + Prop::Ints(v) => array(out, b'i', v.len(), v.iter().flat_map(|&i| (i as i32).to_le_bytes()).collect()), + Prop::Floats(v) => array(out, b'd', v.len(), v.iter().flat_map(|f| f.to_le_bytes()).collect()), + } + } + fn record(out: &mut Vec, n: &FbxNode, wide: bool, compress: bool) { + let w = if wide { 8 } else { 4 }; + let start = out.len(); + out.extend(vec![0u8; 3 * w]); + out.push(n.name.len() as u8); + out.extend(n.name.as_bytes()); + let props = match n.child("a") { + Some(a) if a.props.iter().all(|p| matches!(p, Prop::Int(_))) => vec![Prop::Ints(a.ints())], + Some(a) => vec![Prop::Floats(a.floats())], + None => n.props.clone(), + }; + let pstart = out.len(); + for p in &props { + prop(out, p, compress); + } + let plen = out.len() - pstart; + let kids: Vec<&FbxNode> = n.children.iter().filter(|c| c.name != "a").collect(); + if !kids.is_empty() { + for k in kids { + record(out, k, wide, compress); + } + out.extend(vec![0u8; 3 * w + 1]); + } + let end = out.len(); + for (k, v) in [end, props.len(), plen].into_iter().enumerate() { + let at = start + k * w; + if wide { + out[at..at + 8].copy_from_slice(&(v as u64).to_le_bytes()); + } else { + out[at..at + 4].copy_from_slice(&(v as u32).to_le_bytes()); + } + } + } + let wide = version >= 7500; + let mut out = BINARY_MAGIC.to_vec(); + out.extend(version.to_le_bytes()); + for n in nodes { + record(&mut out, n, wide, compress); + } + out.extend(vec![0u8; if wide { 25 } else { 13 }]); + // Footer bytes after the closing null record. + out.extend([0xfa; 16]); + out +} + +#[test] +fn ascii_fbx_scene() { + let m = load("scene.fbx", ASCII.as_bytes(), &|_| None).unwrap(); + check_scene(&m); +} + +#[test] +fn binary_fbx_matches_ascii() { + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let ascii = parse(ASCII.as_bytes(), &|_| None).unwrap(); + for (version, compress) in [(7400, false), (7400, true), (7500, true)] { + let bin = write_binary(&doc, version, compress); + let (v, nodes) = read(&bin).unwrap(); + assert_eq!(v, version); + assert_eq!(nodes.iter().map(|n| n.name.as_str()).collect::>(), ["FBXHeaderExtension", "GlobalSettings", "Objects", "Connections"]); + // Found by its magic even without the extension. + let m = load("scene.bin", &bin, &|_| None).unwrap(); + assert_eq!(m, ascii, "version {version}, compressed {compress}"); + } +} + +#[test] +fn embedded_and_relative_textures() { + let png = { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([0, 255, 0, 255])); + let mut b = std::io::Cursor::new(vec![]); + img.write_to(&mut b, image::ImageFormat::Png).unwrap(); + b.into_inner() + }; + let objects = r#" + Texture: 600, "Texture::Base", "" { + RelativeFilename: "maps\base.png" + } + Texture: 700, "Texture::Glow", "" { + RelativeFilename: "missing.png" + } + Video: 800, "Video::Glow", "Clip" { + Content: "BASE64" + } +} +Connections: {"# + .replace("BASE64", &b64(&png)); + let doc = ASCII.replace("}\nConnections: {", &objects).replace( + "C: \"OO\",500,300\n", + "C: \"OO\",500,300\n\tC: \"OP\",600,400, \"DiffuseColor\"\n\tC: \"OP\",700,500, \"EmissiveColor\"\n\tC: \"OO\",800,700\n", + ); + let m = load("scene.fbx", doc.as_bytes(), &|uri| (uri == "maps/base.png").then(|| png.clone())).unwrap(); + assert_eq!(m.textures.len(), 2); + let red = m.materials.iter().find(|x| x.name == "Red").unwrap(); + assert_eq!(m.textures[red.base_color_tex.unwrap().texture].data[0], [0.0, 1.0, 0.0, 1.0]); + let glass = m.materials.iter().find(|x| x.name == "Glass").unwrap(); + assert!(glass.emissive_tex.is_some()); + assert_eq!(glass.emissive, [1.0; 3]); +} + +fn b64(b: &[u8]) -> String { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in b.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for k in 0..4 { + if k <= c.len() { + s.push(A[(n >> (18 - 6 * k) & 63) as usize] as char); + } else { + s.push('='); + } + } + } + s +} + +#[test] +fn hostile_fbx_files_fail_cleanly() { + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let bin = write_binary(&doc, 7500, true); + // Every truncation and every single-byte corruption returns (no panic, no runaway). + for n in 0..bin.len() { + let _ = parse(&bin[..n], &|_| None); + } + for i in 0..bin.len() { + let mut b = bin.clone(); + b[i] ^= 0xff; + let _ = parse(&b, &|_| None); + } + // An array claiming 4 G elements. + let mut huge = BINARY_MAGIC.to_vec(); + huge.extend(7400u32.to_le_bytes()); + let body: Vec = b"d".iter().copied().chain(u32::MAX.to_le_bytes()).chain(0u32.to_le_bytes()).chain(8u32.to_le_bytes()).chain([0u8; 8]).collect(); + let start = huge.len(); + let end = start + 13 + 1 + body.len(); + huge.extend((end as u32).to_le_bytes()); + huge.extend(1u32.to_le_bytes()); + huge.extend((body.len() as u32).to_le_bytes()); + huge.push(1); + huge.push(b'V'); + huge.extend(body); + assert!(read(&huge).is_err()); + // Deep nesting, both encodings. + assert!(read("A: {".repeat(100_000).as_bytes()).is_err()); + // Not an FBX scene at all, an old FBX version, and a scene without meshes. + assert!(parse(b"\x00\x01garbage", &|_| None).is_err()); + let v6 = "FBXHeaderExtension: {\n FBXVersion: 6100\n}\nObjects: {\n}\n"; + assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); + assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9480f29c..19a0d37f 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -5,6 +5,9 @@ //! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and //! animations. //! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model). +//! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours). +//! - [`fbx`]: FBX 7.x scenes, binary and ASCII: meshes, the Model hierarchy, Lambert/Phong +//! materials and their textures. //! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder). //! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with //! [`triangulate`] for the caps. @@ -15,9 +18,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod extrude; +pub mod fbx; pub mod gltf; pub mod obj; pub mod prim; +pub mod stl; pub mod triangulate; use std::sync::Arc; @@ -720,13 +725,15 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64 [q.x, q.y, q.z] } -/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension. +/// Which loader handles a file name (`gltf`, `glb`, `obj`, `stl`, `fbx`), by extension. pub fn format_of(path: &str) -> Option<&'static str> { let ext = path.rsplit('.').next()?.to_ascii_lowercase(); match ext.as_str() { "gltf" => Some("gltf"), "glb" => Some("glb"), "obj" => Some("obj"), + "stl" => Some("stl"), + "fbx" => Some("fbx"), _ => None, } } @@ -736,9 +743,12 @@ pub fn format_of(path: &str) -> Option<&'static str> { pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { match format_of(path) { Some("obj") => obj::parse(bytes, resolve), + Some("stl") => stl::parse(bytes), + Some("fbx") => fbx::parse(bytes, resolve), Some(_) => gltf::parse(bytes, resolve), None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve), - None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))), + None if bytes.starts_with(fbx::BINARY_MAGIC) => fbx::parse(bytes, resolve), + None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ, STL or FBX file"))), } } diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs new file mode 100644 index 00000000..6684858a --- /dev/null +++ b/crates/model/src/stl.rs @@ -0,0 +1,203 @@ +//! STL (stereolithography) meshes, binary and ASCII. +//! +//! Written from the public description of the format (3D Systems' StereoLithography Interface +//! Specification, 1989, and its widely documented colour extensions): +//! +//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a +//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last +//! facet are ignored. +//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… / +//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other. +//! Loops with more than three vertices are fan-triangulated. +//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics), +//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour +//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15 +//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a +//! plain dielectric material; uncoloured files get none (the renderer's default material). +//! +//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices +//! with the face normal from its counter-clockwise winding (the stored normal is only used when +//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are +//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`] +//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite +//! coordinates are skipped. + +use std::collections::HashMap; + +use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub}; + +/// Header (80) plus facet count (4). +const HEADER: usize = 84; +/// Bytes per binary facet. +const FACET: usize = 50; + +/// Parse an STL file (binary or ASCII, detected from the contents). +pub fn parse(bytes: &[u8]) -> Result { + let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes); + let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER)); + // A binary file's size is exact; many binary headers still begin with `solid`, so the size + // decides first, then the ASCII keyword. + if needed.is_some_and(|n| n == bytes.len()) { + return binary(bytes); + } + if looks_ascii(bytes) { + return ascii(bytes); + } + if needed.is_some_and(|n| n <= bytes.len()) { + return binary(bytes); + } + Err(perr("not an STL file (truncated binary facets or no `solid` keyword)")) +} + +fn looks_ascii(bytes: &[u8]) -> bool { + let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]); + let text = String::from_utf8_lossy(head); + let t = text.trim_start(); + t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid")) +} + +/// The Z-up → Y-up turn. +fn y_up(v: [f32; 3]) -> [f32; 3] { + [v[0], v[2], -v[1]] +} + +/// Facets grouped into primitives by colour, in order of first use. +#[derive(Default)] +struct Builder { + /// (colour key, primitive); `None` = uncoloured. + prims: Vec<(Option<[u8; 3]>, Primitive)>, + /// Colour → index into `prims` (up to 32 768 colours, so not a linear search). + slots: HashMap, usize>, + facets: usize, +} + +impl Builder { + /// Add one triangle (file coordinates); `stored` is the file's facet normal. + fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) { + if v.iter().flatten().any(|c| !c.is_finite()) { + return; + } + let v = v.map(y_up); + let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0]))) + .or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten()) + .unwrap_or([0.0, 1.0, 0.0]); + let slot = *self.slots.entry(color).or_insert_with(|| { + self.prims.push((color, Primitive::default())); + self.prims.len() - 1 + }); + let Some((_, p)) = self.prims.get_mut(slot) else { return }; + for c in v { + p.indices.push(p.positions.len() as u32); + p.positions.push(c); + p.normals.push(n); + } + self.facets += 1; + } + + fn finish(self, name: String) -> Result { + if self.facets == 0 { + return Err(perr("STL file without facets")); + } + let mut materials = vec![]; + let mut mesh = Mesh { name, primitives: vec![] }; + for (color, mut p) in self.prims { + if let Some(c) = color { + let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0)); + let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]); + materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) }); + p.material = Some(materials.len() - 1); + } + mesh.primitives.push(p); + } + // Uncoloured facets next to coloured ones: a neutral grey of their own. + if !materials.is_empty() { + for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) { + materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) }); + p.material = Some(materials.len() - 1); + } + } + Ok(Model::single(mesh, materials)) + } +} + +fn f32_at(b: &[u8], at: usize) -> f32 { + b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes) +} + +/// A 5-bit channel expanded to 8 bits. +fn five(x: u16) -> u8 { + let x = (x & 31) as u8; + (x << 3) | (x >> 2) +} + +/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored). +fn header_color(header: &[u8]) -> Option<[u8; 3]> { + let at = header.windows(6).position(|w| w == b"COLOR=")?; + let c = header.get(at + 6..at + 10)?; + Some([*c.first()?, *c.get(1)?, *c.get(2)?]) +} + +fn binary(bytes: &[u8]) -> Result { + let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?; + let magics = header_color(header); + let body = bytes.get(HEADER..).unwrap_or(&[]); + let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize; + let mut b = Builder::default(); + for f in body.as_chunks::().0.iter().take(count) { + let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)]; + let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]); + let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes); + let color = match magics { + Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }), + None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]), + }; + b.tri(color, n, v); + } + let text = String::from_utf8_lossy(header); + let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string()) +} + +/// Three numbers from the rest of an ASCII line. +fn three<'a>(it: &mut impl Iterator) -> Option<[f32; 3]> { + Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?]) +} + +fn ascii(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut b = Builder::default(); + let mut name: Option = None; + let mut normal = [0.0f32; 3]; + let mut loop_v: Vec<[f32; 3]> = vec![]; + for (ln, line) in text.lines().enumerate() { + let line = line.trim(); + let mut it = line.split_whitespace(); + let Some(key) = it.next() else { continue }; + let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); + match key.to_ascii_lowercase().as_str() { + "solid" => { + if name.is_none() { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); + } + } + "facet" => { + // `facet normal nx ny nz` + normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]); + loop_v.clear(); + } + "vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?), + "endloop" | "endfacet" => { + if let Some((&first, rest)) = loop_v.split_first() { + for w in rest.windows(2) { + if let [a, c] = w { + b.tri(None, normal, [first, *a, *c]); + } + } + } + loop_v.clear(); + } + _ => {} + } + } + b.finish(name.unwrap_or_default()) +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 032ca47d..9206ef83 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -308,6 +308,35 @@ fn bevels_shrink_the_caps() { assert!(c.positions.len() > m.positions.len()); } +#[test] +fn extrusions_split_into_front_bevel_side_and_back() { + use super::extrude::{Surface, extrude_surfaces, extrude_surfaces_stacked}; + let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] }; + let p = ExtrudeParams { bevel: BevelStyle::Angular, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 }; + let whole = extrude(std::slice::from_ref(&square), &p); + let parts = extrude_surfaces(std::slice::from_ref(&square), &p); + // The parts hold exactly the triangles of the whole mesh. + assert_eq!(parts.iter().map(|m| m.triangle_count()).sum::(), whole.triangle_count()); + let [front, bevel, side, back] = &parts; + // Caps: flat, at the layer plane (front, facing −z) and at the extrusion depth (back, +z). + assert!(front.positions.iter().all(|q| q[2] == 0.0) && front.normals.iter().all(|n| n[2] < -0.99)); + assert!(back.positions.iter().all(|q| q[2] == 50.0) && back.normals.iter().all(|n| n[2] > 0.99)); + // Walls: the full outline, between the bevels (z 10..40), with sideways normals. + assert!(side.positions.iter().all(|q| (10.0..=40.0).contains(&q[2])) && side.normals.iter().all(|n| n[2].abs() < 1e-6)); + assert!(side.positions.iter().any(|q| q[0] == 0.0)); + // Bevels: slanted between the caps and the walls, front and back. + assert!(bevel.triangle_count() > 0 && bevel.normals.iter().all(|n| n[2].abs() > 0.1 && n[2].abs() < 0.99)); + assert!(bevel.positions.iter().any(|q| q[2] == 0.0) && bevel.positions.iter().any(|q| q[2] == 50.0)); + assert_eq!(Surface::ALL.map(Surface::index), [0, 1, 2, 3]); + // Without a bevel there is nothing on the Bevel surface. + let flat = extrude_surfaces(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::None, ..p }); + assert_eq!(flat[Surface::Bevel.index()].triangle_count(), 0); + assert!(flat[Surface::Side.index()].triangle_count() > 0); + // Stacked like `extrude_stacked`. + let up = extrude_surfaces_stacked(std::slice::from_ref(&square), &p, 2); + assert!(up[0].positions.iter().all(|q| (q[2] + 2.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6)); +} + #[test] fn contours_group_into_outlines_by_nesting() { let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]; @@ -373,3 +402,93 @@ fn stacked_extrusions_move_towards_the_camera() { assert_eq!((a[0], a[1]), (b[0], b[1])); } } + +/// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute). +fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec { + let mut b = header.to_vec(); + b.resize(80, 0); + b.extend((facets.len() as u32).to_le_bytes()); + for (n, v, attr) in facets { + for c in n.iter().chain(v.iter().flatten()) { + b.extend(c.to_le_bytes()); + } + b.extend(attr.to_le_bytes()); + } + b +} + +/// The unit square in the XY plane (Z-up file), facing +Z, as two facets. +const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]]; + +#[test] +fn stl_binary_is_faceted_and_turned_y_up() { + // A header that begins with `solid`, as many binary exporters write it. + let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]); + let m = load("part.STL", &bytes, &|_| None).unwrap(); + assert_eq!(format_of("part.STL"), Some("stl")); + assert_eq!(m.meshes[0].name, "part"); + assert!(m.materials.is_empty()); + let p = &m.meshes[0].primitives[0]; + assert_eq!((p.triangle_count(), p.positions.len()), (2, 6)); + assert!(p.uvs.is_empty()); + // Z-up +Z becomes Y-up +Y; file Y becomes −Z. + assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]); + assert_eq!(p.positions[2], [1.0, 0.0, -1.0]); + // Counter-clockwise from outside after the turn. + let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]); + assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0); + // Trailing bytes after the last facet are ignored. + let mut longer = bytes.clone(); + longer.extend([0u8; 7]); + assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes); +} + +#[test] +fn stl_ascii_with_polygon_loops_and_several_solids() { + let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\ + solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n"; + let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].name, "Bracket"); + // The quad fans into two triangles; the second solid adds one. + assert_eq!(m.triangle_count(), 3); + let (lo, hi) = m.bounds().unwrap(); + assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0])); + assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err()); +} + +#[test] +fn stl_binary_colours() { + // VisCAM / SolidView: bit 15 set, red in bits 10–14. + let red = 0x8000 | (31 << 10); + let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap(); + assert_eq!(m.meshes[0].primitives.len(), 2); + assert_eq!(m.materials.len(), 2); + assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5)); + assert_eq!(m.materials[0].metallic, 0.0); + assert_eq!(m.meshes[0].primitives[1].material, Some(1)); + // Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour + // with red in bits 0–4. + let mut header = b"COLOR=".to_vec(); + header.extend([0, 255, 0, 255]); + let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap(); + let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5); + assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]); + assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]); +} + +#[test] +fn stl_hostile_input_is_an_error() { + // Claims four billion facets but has one: truncated. + let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]); + bytes.splice(80..84, u32::MAX.to_le_bytes()); + assert!(stl::parse(&bytes).is_err()); + assert!(stl::parse(b"").is_err()); + assert!(stl::parse(&[0u8; 83]).is_err()); + // No facets, or only non-finite ones. + assert!(stl::parse(&binary_stl(b"", &[])).is_err()); + assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err()); + assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err()); + // A degenerate facet keeps its stored normal. + let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); + assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 71edc7ff..935c03b0 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -265,6 +265,11 @@ pub const TEXT_ANIMATOR_KINDS: &[(&str, &str)] = &[ ("strokeOpacity", "Stroke Color: Opacity"), ("strokeWidth", "Stroke Width"), ("tracking", "Tracking"), + ("-", "-"), + ("frontColor", "Front: Color"), + ("bevelColor", "Bevel: Color"), + ("sideColor", "Side: Color"), + ("backColor", "Back: Color"), ]; fn vec3_prop(ids: &mut Ids, m: &str, name: &str, v: [f64; 3], ui: ParamUi, three_d: bool) -> Property { @@ -318,6 +323,11 @@ pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec vec![pct_slider(ids, "strokeBrightness", "Stroke Brightness", 0.0, -100.0)], "strokeOpacity" => vec![pct_slider(ids, "strokeOpacity", "Stroke Opacity", 100.0, 0.0)], "strokeWidth" => vec![ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)], + // Colours of the extruded characters' surfaces (Advanced 3D). + "frontColor" => vec![ids.prop("frontColor", "Front Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], + "bevelColor" => vec![ids.prop("bevelColor", "Bevel Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], + "sideColor" => vec![ids.prop("sideColor", "Side Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], + "backColor" => vec![ids.prop("backColor", "Back Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)], "tracking" => { let tt = fixed(ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"]))); vec![tt, ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0))] @@ -530,6 +540,35 @@ pub fn shape_repeater(ids: &mut Ids, copies: f64, offset_pos: [f64; 2]) -> PropG .with(tr) } +/// Shape contents items that colour the surfaces of an extruded shape (Advanced 3D): (match +/// id, name), in Front, Bevel, Side, Back order. Each applies to every paint of its group and +/// the groups nested in it. +pub const SHAPE_SURFACES: [(&str, &str); 4] = [("front", "Front"), ("bevel", "Bevel"), ("side", "Side"), ("back", "Back")]; + +/// The `layer.addShapeItem` kind of a surface property (`sideColor`): (surface match id, +/// property match id). +pub fn shape_surface_kind(kind: &str) -> Option<(&'static str, &'static str)> { + let (surface, prop) = SHAPE_SURFACES.iter().find_map(|(m, _)| kind.strip_prefix(m).map(|rest| (*m, rest)))?; + match prop { + "Color" => Some((surface, "color")), + _ => None, + } +} + +/// A surface property of a shape surface item (see [`SHAPE_SURFACES`]). +pub fn shape_surface_prop(ids: &mut Ids, prop: &str) -> Option { + match prop { + "color" => Some(ids.prop("color", "Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)), + _ => None, + } +} + +/// An empty shape surface item (`front`, `bevel`, `side` or `back`). +pub fn shape_surface(ids: &mut Ids, surface: &str) -> Option { + let (m, name) = SHAPE_SURFACES.iter().find(|(m, _)| *m == surface)?; + Some(ids.group(m, name)) +} + pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { let g = match kind { "round" => indexed(ids.group("round", "Round Corners 1")).with(ids.prop("radius", "Radius", Value::Scalar(10.0)).with_ui(ParamUi::Pixels)), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c1f110e0..fee3b8ce 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod essential; +pub mod limits; pub mod props; pub mod render_queue; pub mod render_templates; @@ -48,6 +49,42 @@ pub fn saved_by(text: &str) -> Option { serde_json::from_str::
(text).ok()?.saved_by } +/// The compositions that were open when a project file was saved (`activeComp` and `openComps` +/// next to `savedBy`), so it reopens where it was left (#748). Editor state rather than project +/// content: opening a comp doesn't make the project unsaved, and it isn't part of +/// [`Project`]. Older files have none; older versions ignore it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SavedView { + /// The comp shown in the viewer and the Timeline. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_comp: Option, + /// The open comps (Timeline tabs), in tab order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub open_comps: Vec, +} + +/// The view a project file was saved with ([`SavedView`]); empty for older files. Values that +/// aren't ids are ignored (whether an id still names a comp is up to the caller). +pub fn saved_view(text: &str) -> SavedView { + #[derive(Deserialize)] + struct Meta { + #[serde(rename = "activeComp", default)] + active_comp: Option, + #[serde(rename = "openComps", default)] + open_comps: Option, + } + let Ok(m) = serde_json::from_str::(text) else { return SavedView::default() }; + SavedView { + active_comp: m.active_comp.as_ref().and_then(serde_json::Value::as_u64).map(ItemId), + open_comps: m + .open_comps + .as_ref() + .and_then(serde_json::Value::as_array) + .map_or_else(Vec::new, |a| a.iter().filter_map(serde_json::Value::as_u64).map(ItemId).collect()), + } +} + /// Whether version `a` (`major.minor.patch`, pre-release suffixes ignored) is newer than `b`. pub fn is_newer_version(a: &str, b: &str) -> bool { let parts = |v: &str| -> [u64; 3] { @@ -301,6 +338,71 @@ pub enum Renderer { Advanced3D, } +/// A composition's Resolution (the Composition panel's Resolution/Down Sample Factor popup, View ▸ +/// Resolution): how many of its pixels the viewer renders. Auto follows the magnification. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Resolution { + #[default] + Auto, + Full, + Half, + Third, + Quarter, + /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). + Custom(u8), +} + +impl Resolution { + pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; + pub fn label(self) -> &'static str { + match self { + Resolution::Auto => "Auto", + Resolution::Full => "Full", + Resolution::Half => "Half", + Resolution::Third => "Third", + Resolution::Quarter => "Quarter", + Resolution::Custom(_) => "Custom", + } + } + pub fn is_auto(&self) -> bool { + *self == Resolution::Auto + } + /// The down-sample factor (render every n-th pixel); `None` for Auto. + pub fn factor(self) -> Option { + match self { + Resolution::Auto => None, + Resolution::Full => Some(1), + Resolution::Half => Some(2), + Resolution::Third => Some(3), + Resolution::Quarter => Some(4), + Resolution::Custom(n) => Some(n), + } + } + /// The resolution that renders every `n`-th pixel (1 = Full … 4 = Quarter; up to 40). + pub fn from_factor(n: u64) -> Resolution { + match n.clamp(1, 40) { + 1 => Resolution::Full, + 2 => Resolution::Half, + 3 => Resolution::Third, + 4 => Resolution::Quarter, + n => Resolution::Custom(n as u8), + } + } + /// Render scale for a viewer magnification (and display pixel density). + pub fn scale(self, zoom: f32, ppp: f32) -> f64 { + match self.factor() { + Some(n) => 1.0 / f64::from(n.clamp(1, 40)), + // As in After Effects, Auto renders the pixels the magnification needs: the coarsest + // whole factor that still gives every screen pixel a rendered one (Full above 50 %, + // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). + None => { + let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); + if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } + } + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Comp { pub width: u32, @@ -354,6 +456,11 @@ pub struct Comp { /// Essential Graphics: the controls this comp exposes to instances and templates. #[serde(default, skip_serializing_if = "Option::is_none")] pub essential: Option, + /// The Composition panel's Resolution, saved with the project as in After Effects. It changes + /// what the viewer renders, not the comp's pixels ([`Comp::same_pixels`]), and stays out of + /// the undo history. + #[serde(default, skip_serializing_if = "Resolution::is_auto")] + pub resolution: Resolution, } /// A viewer guide line: vertical guides sit at an x position, horizontal ones at a y position. @@ -399,6 +506,7 @@ impl Comp { guides: vec![], global_light: styles::GlobalLight::default(), essential: None, + resolution: Resolution::Auto, } } /// Whether the composition has a frame at its time `t`: nested, it shows nothing before it @@ -698,8 +806,8 @@ impl Layer { } impl Comp { - /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and - /// Hide Shy Layers. + /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide + /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { // Every field (no `..`: a new field must be considered here); the layers last. let Comp { @@ -728,6 +836,7 @@ impl Comp { global_light, guides, essential, + resolution: _, } = self; *width == o.width && *height == o.height @@ -756,14 +865,15 @@ impl Comp { && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) } - /// The comp with those switches at their defaults (borrowed when they already are), for - /// keys that hash the whole comp. + /// The comp with those switches and the Resolution at their defaults (borrowed when they + /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; + c.resolution = Resolution::Auto; for l in &mut c.layers { l.switches = l.switches.pixels(); } @@ -1246,7 +1356,9 @@ impl Project { _ => None, } } - pub fn add_item(&mut self, name: &str, label: Label, parent: Option, kind: ItemKind) -> ItemId { + /// Adds an item; its size, frame rate and the like are brought within [`limits`] first. + pub fn add_item(&mut self, name: &str, label: Label, parent: Option, mut kind: ItemKind) -> ItemId { + limits::repair_kind(name, &mut kind, &mut vec![]); let id = ItemId(self.alloc()); self.items.insert(id, Item { id, name: name.into(), label, comment: String::new(), parent, kind, proxy: None }); id @@ -1315,14 +1427,20 @@ impl Project { /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or /// infinity: such a value would be written as `null`). pub fn to_file_json(&self) -> Result { + self.to_file_json_with_view(&SavedView::default()) + } + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). + pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { #[derive(Serialize)] struct File<'a> { #[serde(rename = "savedBy")] saved_by: &'a str, #[serde(flatten)] + view: &'a SavedView, + #[serde(flatten)] project: &'a Project, } - let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, project: self }) + let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self }) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; @@ -1336,12 +1454,22 @@ impl Project { serde_json::to_string(self).unwrap_or_default() } pub fn from_json(s: &str) -> Result { + Project::from_json_repaired(s).map(|(p, _)| p) + } + /// [`Project::from_json`], also returning one line per value that was out of [`limits`] and + /// was corrected (a composition of a billion pixels, a zero frame rate…), so a corrupt or + /// hostile file still opens, within bounds, and the user can be told what changed. + pub fn from_json_repaired(s: &str) -> Result<(Project, Vec), ProjectError> { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; if p.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!("project schema {} is newer than this version supports ({SCHEMA_VERSION})", p.schema))); } p.fix_next_id(); - Ok(p) + let mut notes = vec![]; + for item in p.items.values_mut() { + limits::repair_item(item, &mut notes); + } + Ok((p, notes)) } } diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs new file mode 100644 index 00000000..998f4022 --- /dev/null +++ b/crates/project/src/limits.rs @@ -0,0 +1,227 @@ +//! The bounds every project holds to (#788). After Effects limits compositions, solids and +//! imported images to 30,000 × 30,000 pixels; the Composition Settings commands already used that +//! limit, a frame-rate range and a motion-blur sample range. A project file is input like any +//! other: [`Project::from_json_repaired`](crate::Project::from_json_repaired) brings what it reads +//! into these bounds (and says what it changed) and [`Project::add_item`](crate::Project::add_item) +//! does the same for every new item, so a corrupt or hostile file can't make the renderer allocate +//! a frame of billions of pixels or step through zero-length frames. + +use std::fmt::Display; +use std::sync::Arc; + +use effectcraft_time::FrameRate; + +use crate::{Comp, Footage, Item, ItemKind, Solid}; + +/// The largest width or height of a composition, solid or footage frame, in pixels. +pub const MAX_SIZE: u32 = 30_000; +/// The slowest frame rate, in frames per second. +pub const MIN_FPS: f64 = 0.001; +/// The fastest frame rate, in frames per second. +pub const MAX_FPS: f64 = 1000.0; +/// The narrowest pixel aspect ratio. +pub const MIN_PIXEL_ASPECT: f64 = 0.1; +/// The widest pixel aspect ratio. +pub const MAX_PIXEL_ASPECT: f64 = 10.0; +/// Composition Settings ▸ Advanced ▸ Samples Per Frame. +pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); +/// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. +pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); + +/// A width or height from 1 to [`MAX_SIZE`]. +pub fn size(v: u64) -> u32 { + // In range after the clamp, so the cast can't truncate. + v.clamp(1, u64::from(MAX_SIZE)) as u32 +} + +/// [`size`] of a measured length (rounded; 1 for NaN). +pub fn size_f64(v: f64) -> u32 { + if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIZE)) as u32 } else { 1 } +} + +/// A pixel aspect ratio from [`MIN_PIXEL_ASPECT`] to [`MAX_PIXEL_ASPECT`] (square for a value +/// that isn't a positive number). +pub fn pixel_aspect(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.clamp(MIN_PIXEL_ASPECT, MAX_PIXEL_ASPECT) } else { 1.0 } +} + +/// A frame rate from [`MIN_FPS`] to [`MAX_FPS`]; `fallback` for a zero or negative rate. +pub fn frame_rate(r: FrameRate, fallback: FrameRate) -> FrameRate { + if r.num <= 0 || r.den <= 0 { + return fallback; + } + let fps = r.as_f64(); + if (MIN_FPS..=MAX_FPS).contains(&fps) { r } else { FrameRate::from_f64(fps.clamp(MIN_FPS, MAX_FPS)) } +} + +/// Records one corrected value. +fn note(notes: &mut Vec, item: &str, what: &str, from: impl Display, to: impl Display) { + notes.push(format!("{item}: {what} {from} → {to}")); +} + +fn fps(r: FrameRate) -> String { + format!("{}/{} fps", r.num, r.den) +} + +/// Brings a read item (and its proxy) within the limits; one line per corrected value is added +/// to `notes`. +pub(crate) fn repair_item(item: &mut Item, notes: &mut Vec) { + repair_kind(&item.name, &mut item.kind, notes); + if let Some(proxy) = &mut item.proxy { + repair_footage(&format!("{} (proxy)", item.name), &mut proxy.footage, notes); + } +} + +/// Brings `kind` within the limits; one line per corrected value is added to `notes`. +pub(crate) fn repair_kind(name: &str, kind: &mut ItemKind, notes: &mut Vec) { + match kind { + ItemKind::Comp(c) => { + if let Some(fixed) = repair_comp(name, c, notes) { + *c = Arc::new(fixed); + } + } + ItemKind::Solid(s) => repair_solid(name, s, notes), + ItemKind::Footage(f) => repair_footage(name, f, notes), + ItemKind::Folder => {} + } +} + +/// The comp with its settings within the limits, or `None` when they already are (so a +/// composition shared with undo snapshots is only copied when something changes). +fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { + let width = c.width.clamp(1, MAX_SIZE); + let height = c.height.clamp(1, MAX_SIZE); + let pa = pixel_aspect(c.pixel_aspect); + let rate = frame_rate(c.frame_rate, FrameRate::FPS_29_97); + // At least one frame long. + let duration = c.duration.max(rate.frame_duration()); + let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); + let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let unchanged = (width, height, rate, duration, samples, adaptive) + == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) + && pa.to_bits() == c.pixel_aspect.to_bits(); + if unchanged { + return None; + } + let mut n = c.clone(); + if (width, height) != (c.width, c.height) { + note(notes, name, "size", format_args!("{} × {} px", c.width, c.height), format_args!("{width} × {height} px")); + } + if pa.to_bits() != c.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", c.pixel_aspect, pa); + } + if rate != c.frame_rate { + note(notes, name, "frame rate", fps(c.frame_rate), fps(rate)); + } + if duration != c.duration { + note(notes, name, "duration", format_args!("{:.3} s", c.duration.seconds()), format_args!("{:.3} s", duration.seconds())); + n.work_area = (effectcraft_time::Tick::ZERO, duration); + } + if samples != c.motion_blur_samples { + note(notes, name, "motion blur samples per frame", c.motion_blur_samples, samples); + } + if adaptive != c.motion_blur_adaptive_limit { + note(notes, name, "adaptive sample limit", c.motion_blur_adaptive_limit, adaptive); + } + (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); + (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + Some(n) +} + +fn repair_solid(name: &str, s: &mut Solid, notes: &mut Vec) { + let (w, h) = (s.width.clamp(1, MAX_SIZE), s.height.clamp(1, MAX_SIZE)); + if (w, h) != (s.width, s.height) { + note(notes, name, "size", format_args!("{} × {} px", s.width, s.height), format_args!("{w} × {h} px")); + (s.width, s.height) = (w, h); + } + let pa = pixel_aspect(s.pixel_aspect); + if pa.to_bits() != s.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", s.pixel_aspect, pa); + s.pixel_aspect = pa; + } +} + +fn repair_footage(name: &str, f: &mut Footage, notes: &mut Vec) { + // Audio-only footage has no frame (0 × 0). + let (w, h) = (f.width.min(MAX_SIZE), f.height.min(MAX_SIZE)); + if (w, h) != (f.width, f.height) { + note(notes, name, "size", format_args!("{} × {} px", f.width, f.height), format_args!("{w} × {h} px")); + (f.width, f.height) = (w, h); + } + let pa = pixel_aspect(f.pixel_aspect); + if pa.to_bits() != f.pixel_aspect.to_bits() { + note(notes, name, "pixel aspect ratio", f.pixel_aspect, pa); + f.pixel_aspect = pa; + } + let rate = frame_rate(f.frame_rate, FrameRate::FPS_30); + if rate != f.frame_rate { + note(notes, name, "frame rate", fps(f.frame_rate), fps(rate)); + f.frame_rate = rate; + } + if let Some(native) = f.native_rate { + let rate = frame_rate(native, FrameRate::FPS_30); + if rate != native { + note(notes, name, "native frame rate", fps(native), fps(rate)); + f.native_rate = Some(rate); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_time::Tick; + + #[test] + fn helpers_clamp_and_refuse_nonsense() { + assert_eq!(size(0), 1); + assert_eq!(size(u64::from(u32::MAX) + 5), MAX_SIZE); + assert_eq!(size_f64(f64::NAN), 1); + assert_eq!(size_f64(1e12), MAX_SIZE); + assert_eq!(pixel_aspect(0.0), 1.0); + assert_eq!(pixel_aspect(-2.0), 1.0); + assert_eq!(pixel_aspect(1e9), MAX_PIXEL_ASPECT); + assert_eq!(frame_rate(FrameRate { num: 0, den: 1 }, FrameRate::FPS_25), FrameRate::FPS_25); + assert_eq!(frame_rate(FrameRate { num: 24, den: -1 }, FrameRate::FPS_25), FrameRate::FPS_25); + assert_eq!(frame_rate(FrameRate::new(1_000_000_000_000_000_000, 1), FrameRate::FPS_25).as_f64(), MAX_FPS); + assert_eq!(frame_rate(FrameRate::FPS_29_97, FrameRate::FPS_25), FrameRate::FPS_29_97); + } + + #[test] + fn add_item_and_reading_a_file_hold_footage_to_the_limits() { + let mut p = crate::Project::default(); + let wild = Footage { width: u32::MAX, height: 0, pixel_aspect: 0.0, frame_rate: FrameRate { num: 1, den: 0 }, ..Default::default() }; + let id = p.add_item("Clip", effectcraft_color::Label::None, None, ItemKind::Footage(wild)); + let ItemKind::Footage(f) = &p.items[&id].kind else { panic!("footage") }; + assert_eq!((f.width, f.height, f.pixel_aspect, f.frame_rate), (MAX_SIZE, 0, 1.0, FrameRate::FPS_30)); + // The same values in a file are corrected, and each correction is reported. + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + let kind = &mut v["items"][id.0.to_string()]["kind"]; + kind["width"] = serde_json::json!(4_000_000_000u64); + kind["native_rate"] = serde_json::json!({"num": -24, "den": 1}); + let mut proxy = kind.clone(); + proxy["frame_rate"] = serde_json::json!({"num": 0, "den": 0}); + v["items"][id.0.to_string()]["proxy"] = serde_json::json!({"footage": proxy}); + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let ItemKind::Footage(b) = &back.items[&id].kind else { panic!("footage") }; + assert_eq!((b.width, b.native_rate), (MAX_SIZE, Some(FrameRate::FPS_30))); + assert_eq!( + notes, + [ + "Clip: size 4000000000 × 0 px → 30000 × 0 px", + "Clip: native frame rate -24/1 fps → 30/1 fps", + "Clip (proxy): size 4000000000 × 0 px → 30000 × 0 px", + "Clip (proxy): frame rate 0/0 fps → 30/1 fps", + "Clip (proxy): native frame rate -24/1 fps → 30/1 fps", + ] + ); + } + + #[test] + fn a_comp_in_range_is_not_copied() { + let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); + let mut notes = vec![]; + assert!(repair_comp("Comp", &c, &mut notes).is_none()); + assert!(notes.is_empty()); + } +} diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index d74e183d..5c13dffc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -522,14 +522,18 @@ pub enum OutputFormat { /// AV1 video (+ AAC audio) in MP4 (`av01`). WebM with AV1: [`OutputFormat::WebM`] with /// [`WebmVideoCodec::Av1`]. Av1, + /// Uncompressed 8-bit RGB / ARGB (`raw `) in QuickTime (+ PCM audio), for handing to an + /// external encoder. + Uncompressed, } impl OutputFormat { - pub const ALL: [OutputFormat; 12] = [ + pub const ALL: [OutputFormat; 13] = [ OutputFormat::H264, OutputFormat::Hevc, OutputFormat::Av1, OutputFormat::ProRes, + OutputFormat::Uncompressed, OutputFormat::WebM, OutputFormat::PngSequence, OutputFormat::JpegSequence, @@ -554,12 +558,13 @@ impl OutputFormat { OutputFormat::Aiff => "AIFF", OutputFormat::Hevc => "HEVC (H.265)", OutputFormat::Av1 => "AV1", + OutputFormat::Uncompressed => "QuickTime (Uncompressed)", } } pub fn extension(self) -> &'static str { match self { OutputFormat::H264 => "mp4", - OutputFormat::ProRes => "mov", + OutputFormat::ProRes | OutputFormat::Uncompressed => "mov", OutputFormat::PngSequence => "png", OutputFormat::JpegSequence => "jpg", OutputFormat::TiffSequence => "tif", @@ -575,7 +580,7 @@ impl OutputFormat { matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) } pub fn is_movie(self) -> bool { - matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1) + matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1) } /// Formats whose video codec options live in [`OutputModule::codec`] (HEVC, AV1). pub fn has_codec_options(self) -> bool { @@ -597,8 +602,8 @@ impl OutputFormat { let even = |v: u32| if v < 2 { 2 } else { v & !1 }; if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) } } - /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`, - /// `exr`, `gif`, or a label. + /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`, + /// `tiff`/`tif`, `exr`, `gif`, or a label. pub fn from_name(s: &str) -> Option { let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], ""); Some(match n.as_str() { @@ -606,6 +611,7 @@ impl OutputFormat { "hevc" | "h265" | "hevch265" | "hvc1" | "x265" => OutputFormat::Hevc, "av1" | "av01" | "mp4av1" => OutputFormat::Av1, "prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes, + "uncompressed" | "quicktimeuncompressed" | "rawvideo" => OutputFormat::Uncompressed, "png" | "pngsequence" => OutputFormat::PngSequence, "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence, @@ -1637,6 +1643,25 @@ mod tests { assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0)); } + #[test] + fn uncompressed_quicktime_format() { + let f = OutputFormat::from_name("uncompressed").unwrap(); + assert_eq!(f, OutputFormat::Uncompressed); + assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f)); + assert!(OutputFormat::ALL.contains(&f)); + assert!(f.is_movie() && f.supports_alpha() && f.supports_audio()); + assert_eq!(f.coded_size(3, 5), (3, 5)); + // `.mov` still guesses ProRes; switching keeps the name. + assert_eq!(OutputFormat::from_path("a.mov"), Some(OutputFormat::ProRes)); + let mut m = OutputModule::for_format(OutputFormat::ProRes); + m.output = "/out/Comp 1.mov".into(); + m.channels = Channels::Rgba; + m.set_format(f); + assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1.mov", Channels::Rgba)); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap().format, f); + } + #[test] fn hevc_av1_codec_options() { assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc)); diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index edf9a256..494c2a9e 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -65,6 +65,54 @@ fn animate_and_roundtrip_json() { let _ = Keyframe::new(Tick::ZERO, Value::Scalar(0.0)); } +#[test] +fn comp_resolution_is_saved_and_old_projects_load_with_auto() { + use crate::Resolution; + let (mut p, cid) = project_with_layer(); + // Auto (the default) isn't written, so older versions read the file as before. + assert!(!p.to_file_json().unwrap().contains("\"resolution\"")); + p.comp_mut(cid).unwrap().resolution = Resolution::Custom(5); + let q = Project::from_json(&p.to_file_json().unwrap()).unwrap(); + assert_eq!(q.comp(cid).unwrap().resolution, Resolution::Custom(5)); + p.comp_mut(cid).unwrap().resolution = Resolution::Half; + let text = p.to_file_json().unwrap(); + assert_eq!(Project::from_json(&text).unwrap().comp(cid).unwrap().resolution, Resolution::Half); + // A project saved before comps had a resolution opens with Auto. + fn strip(v: &mut serde_json::Value) { + match v { + serde_json::Value::Object(m) => { + m.remove("resolution"); + m.values_mut().for_each(strip); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old: serde_json::Value = serde_json::from_str(&text).unwrap(); + strip(&mut old); + assert_eq!(Project::from_json(&old.to_string()).unwrap().comp(cid).unwrap().resolution, Resolution::Auto); + // It's not part of the comp's pixels. + let (a, b) = (p.comp(cid).unwrap(), q.comp(cid).unwrap()); + assert!(a != b && a.same_pixels(b)); + assert_eq!(format!("{:?}", a.pixel_form()), format!("{:?}", b.pixel_form())); +} + +#[test] +fn resolution_factors_and_scales() { + use crate::Resolution; + assert_eq!(Resolution::from_factor(1), Resolution::Full); + assert_eq!(Resolution::from_factor(4), Resolution::Quarter); + assert_eq!(Resolution::from_factor(0), Resolution::Full); + assert_eq!(Resolution::from_factor(7), Resolution::Custom(7)); + assert_eq!(Resolution::from_factor(1000), Resolution::Custom(40)); + assert_eq!(Resolution::Custom(5).factor(), Some(5)); + assert_eq!(Resolution::Auto.factor(), None); + // A hostile factor from a file renders within the Custom range. + assert_eq!(Resolution::Custom(0).scale(1.0, 1.0), 1.0); + assert_eq!(Resolution::Custom(255).scale(1.0, 1.0), 1.0 / 40.0); + assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0); +} + #[test] fn unique_names_and_cycles() { let (p, cid) = project_with_layer(); @@ -121,3 +169,16 @@ fn project_files_name_their_version_and_always_read_back() { assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); } + +#[test] +fn project_files_remember_the_open_comps() { + let (p, cid) = project_with_layer(); + let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid] }; + let text = p.to_file_json_with_view(&view).unwrap(); + assert_eq!(crate::saved_view(&text), view); + assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); + // Files from before #748 have no view; a garbled one is ignored, not an error. + assert_eq!(crate::saved_view(&p.to_file_json().unwrap()), crate::SavedView::default()); + let odd = text.replacen(&format!("\"activeComp\": {}", cid.0), "\"activeComp\": \"Main\"", 1); + assert_eq!(crate::saved_view(&odd), crate::SavedView { active_comp: None, open_comps: vec![cid] }); +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index d221ac59..4f2ffc52 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -74,7 +74,8 @@ pub fn audible(project: &Project, l: &Layer) -> bool { }) } -fn db_to_gain(db: f64) -> f32 { +/// Linear gain of an Audio Levels value in dB; -96 dB and below is silence. +pub fn db_to_gain(db: f64) -> f32 { if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 } } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index a420256e..abfa791e 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -15,7 +15,9 @@ //! are linearised just before they are transformed and blended, the comp accumulates in linear //! and is encoded back to the working space when the comp is done (so precomps hand encoded //! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB -//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage. +//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage +//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once +//! ([`Pipe::float_curve`]). use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; @@ -68,6 +70,20 @@ impl Pipe { f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() }) } + /// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when + /// Interpret As Linear Light encodes it afterwards (once, with + /// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and + /// the sRGB curve otherwise (the working space converts from there). + pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve { + if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() } + } + + /// The curve float footage is encoded with when nothing interprets it afterwards: the + /// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB. + pub fn float_encoding(&self) -> crate::FloatCurve { + if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb } + } + /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for /// Preserve RGB, whose values enter the working space as the file stores them. pub fn media_in(&self, f: &Footage) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a..cd56b0bc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -23,6 +23,7 @@ pub mod text; pub mod three_d; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; pub use auto::{AutoKey, AutoPick}; pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore}; @@ -36,6 +37,21 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size}; use rayon::prelude::*; +/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are +/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other +/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always +/// reads the values as stored. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum FloatCurve { + /// The sRGB curve (managed projects, and Working Gamma 2.2). + #[default] + Srgb, + /// A pure 2.4 power curve (no working space, Working Gamma 2.4). + Gamma24, + /// The linear values as stored (Interpret As Linear Light encodes them afterwards). + Linear, +} + /// Supplies decoded footage frames (implemented by the media layer). pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). @@ -47,6 +63,16 @@ pub trait FootageSource: Send + Sync { fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option> { self.frame(item, footage, t) } + /// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float + /// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]). + /// Sources without float footage can leave the default, which ignores `float`. + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + let _ = float; + match size { + Some((w, h)) => self.frame_at_size(item, footage, t, w, h), + None => self.frame(item, footage, t), + } + } /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; /// a sample boundary uses the earliest representable tick that selects that sample. @@ -85,6 +111,93 @@ pub trait FootageSource: Send + Sync { fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option> { None } + /// Run `f`, which reads frames from this source, as work that can be called off: once + /// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding + /// forward) stop early and their frames come back `None`. Sources that can't stop a decode + /// just run `f`. See [`CancellableFootage`]. + fn cancellable(&self, _cancel: &Arc, f: &mut dyn FnMut()) { + f() + } + /// Reads through this source have been called off: frames may have come back `None` that + /// exist, so what was made from them must not be kept. + fn called_off(&self) -> bool { + false + } + /// Why `footage` shows no picture although its file is there: a read of it failed because + /// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged + /// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it + /// decodes, and for sources that don't track failures. Stays set until the file is forgotten. + fn problem(&self, _footage: &Footage) -> Option { + None + } +} + +/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` +/// (a viewer frame nobody waits for any more, #329). Every read goes through +/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread +/// (a parallel loop) answer to this render's flag, and reads for other renders never do. +pub struct CancellableFootage<'a> { + pub inner: &'a dyn FootageSource, + pub cancel: Arc, +} + +impl CancellableFootage<'_> { + fn read(&self, f: impl FnOnce() -> Option) -> Option { + if self.cancel.load(Ordering::Relaxed) { + return None; + } + let (mut f, mut out) = (Some(f), None); + self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f())); + out + } +} + +impl FootageSource for CancellableFootage<'_> { + fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.read(|| self.inner.frame(item, footage, t)) + } + fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { + self.read(|| self.inner.frame_at_size(item, footage, t, width, height)) + } + fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + self.inner.audio(item, footage, t, frames, rate) + } + fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { + self.inner.aux(item, footage, t) + } + fn model(&self, item: ItemId, footage: &Footage) -> Option> { + self.inner.model(item, footage) + } + fn set_cache_budget(&self, bytes: usize) { + self.inner.set_cache_budget(bytes) + } + fn purge(&self) { + self.inner.purge() + } + fn forget(&self, path: &str) { + self.inner.forget(path) + } + fn set_conform_folder(&self, folder: Option) { + self.inner.set_conform_folder(folder) + } + fn cache_budget(&self) -> Option { + self.inner.cache_budget() + } + fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option> { + self.inner.vector_frame(item, footage, scale) + } + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + self.inner.cancellable(cancel, f) + } + fn called_off(&self) -> bool { + self.cancel.load(Ordering::Relaxed) || self.inner.called_off() + } + fn problem(&self, footage: &Footage) -> Option { + self.inner.problem(footage) + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.read(|| self.inner.frame_with(item, footage, t, size, float)) + } } /// Footage that can be rasterised at any scale (SVG). @@ -377,6 +490,16 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -579,6 +702,12 @@ impl<'a> Renderer<'a> { } } + /// The layer cache to keep this render's buffers in: none once its footage reads were + /// called off, as they may have come back empty ([`FootageSource::called_off`], #329). + fn cache_to_keep(&self) -> Option<&'a LayerCache> { + self.cache.filter(|_| !self.footage.called_off()) + } + /// A layer's Quality switch, limited by the precomp layers above it when /// [`RenderOpts::nested_switches`] is on. pub fn quality(&self, layer: &Layer) -> Quality { @@ -732,15 +861,14 @@ impl<'a> Renderer<'a> { Region::Full } - /// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are - /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — - /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so - /// vector content is drawn at its final size and stays sharp when scaled up). + /// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers, + /// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector + /// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded + /// up, so vector content is drawn at its final size and stays sharp when scaled up). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; let continuous = match layer.source { - LayerSource::Text => true, - LayerSource::Shape => layer.switches.collapse, + LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } @@ -991,6 +1119,15 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; + // #659: an authored layer's buffer spans only its painted content while the stack works + // on the layer's whole region (`layer_size`): give the buffer the room a sized source + // already has, so tiling or displacement can place pixels outside the content instead + // of losing them at its edge. Only when something will actually run, and not for an + // adjustment layer, whose buffer is the comp below and already spans it. + if !adjustment + && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { + target.cover(layer_size); + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { @@ -1109,7 +1246,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame(*item, pf, lt)?; + let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -1220,7 +1357,7 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?; + let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; @@ -1234,6 +1371,7 @@ impl<'a> Renderer<'a> { fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); + let float = self.pipe.float_curve(f); if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let (i, w) = frame_position(t, f.frame_rate); if w > 1e-6 { @@ -1241,7 +1379,7 @@ impl<'a> Renderer<'a> { let key = self.cache.map(|_| { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h); + (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -1249,21 +1387,18 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame(item, f, f.frame_rate.tick_of(i)); - let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1)); + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); } return Some(Arc::new(img)); } } } - match size { - Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h), - None => self.footage.frame(item, f, t), - } + self.footage.frame_with(item, f, t, size, float) } /// Source → masks, clamped/quantised to the project depth. @@ -1304,7 +1439,7 @@ impl<'a> Renderer<'a> { let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); - if let (Some(cache), Some(k)) = (self.cache, key) { + if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) { cache.insert(k, nb.clone()); } nb @@ -1322,7 +1457,7 @@ impl<'a> Renderer<'a> { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, flat.clone()); } Some((flat, key)) @@ -1353,7 +1488,7 @@ impl<'a> Renderer<'a> { } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, buf.clone()); } Some((buf, key)) @@ -1378,7 +1513,7 @@ impl<'a> Renderer<'a> { buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); } let buf = Arc::new(buf); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, buf.clone()); } Some(buf) @@ -1410,7 +1545,7 @@ impl<'a> Renderer<'a> { } let body = Arc::new(st.body); let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, body.clone()); for (i, (b, _)) in passes.iter().enumerate() { c.insert(cache::derive(k, i as u64 + 1), b.clone()); @@ -2034,7 +2169,7 @@ impl<'a> Renderer<'a> { let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); - if let (Some(c), Some(k)) = (self.cache, key) { + if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { c.insert(k, foot.clone()); } Some(foot) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index e3cbca4a..aa46fa50 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -6,7 +6,9 @@ //! the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; -//! - items higher in the list draw on top. +//! - items higher in the list draw on top; +//! - surface items (Front, Bevel, Side, Back) colour those surfaces of the Advanced 3D +//! extrusion of every paint in their group and its nested groups (the innermost wins). use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; @@ -53,8 +55,14 @@ struct Draw { xf: Mat3, opacity: f32, blend: BlendMode, + /// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D). + surfaces: SurfaceColors, } +/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`] +/// index (Front, Bevel, Side, Back); `None` keeps the paint's colour. +pub type SurfaceColors = [Option<[f32; 3]>; 4]; + fn mat_path(path: &BezPath, m: &Mat3) -> BezPath { effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default() } @@ -179,6 +187,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; + let mut surfaces: SurfaceColors = [None; 4]; for node in &contents.children { let Node::Group(g) = node else { continue }; if !g.enabled { @@ -224,6 +233,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -236,6 +246,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -258,6 +269,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -386,7 +398,24 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec {} + m => { + if let Some(i) = effectcraft_project::build::SHAPE_SURFACES.iter().position(|(s, _)| *s == m) + && g.get("color").is_some() + && let Some(slot) = surfaces.get_mut(i) + { + let c = ctx.color(layer, g, "color"); + *slot = Some([c[0], c[1], c[2]]); + } + } + } + } + // Surface items colour every paint of the group, wherever they sit in it; nested groups' + // own surface items were applied first and win. + if surfaces.iter().any(Option::is_some) { + for d in &mut draws { + for (s, g) in d.surfaces.iter_mut().zip(surfaces) { + *s = s.or(g); + } } } (draws, live) @@ -622,8 +651,9 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec< /// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills /// (gradient fills: the colour half way along) and strokes, each stroke turned into the region -/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved). -pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [f32; 4])> { +/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved), +/// with the colours of their extrusion's surfaces. +pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec, [f32; 4], SurfaceColors)> { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); draws @@ -641,7 +671,7 @@ pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]), Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]), }; - Some((region, [c[0], c[1], c[2], c[3] * d.opacity])) + Some((region, [c[0], c[1], c[2], c[3] * d.opacity], d.surfaces)) }) .collect() } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7e065391..45c00a64 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -135,6 +135,32 @@ fn track_matte_alpha() { assert!(img.get(150, 50)[3] < 0.01); } +/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain +/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864). +#[test] +fn track_matte_kinds_keep_and_invert() { + for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] { + let (mut p, cid, comp) = setup(); + let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50); + matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + matte.switches.video = false; + let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + fill.track_matte = Some(TrackMatte { layer: matte.id, kind }); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(matte); + c.layers.push(fill); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]); + if inside { + assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } else { + assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside"); + assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour"); + } + } +} + #[test] fn shape_layer_draws_fill_and_stroke() { let (mut p, cid, comp) = setup(); @@ -268,6 +294,23 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC r.comp_frame(cid, t) } +/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer +/// made from a frame that came back empty (an effect's layer parameter) would be reused by the +/// next render of that time. +#[test] +fn a_called_off_render_keeps_nothing_in_the_layer_cache() { + let (p, cid) = cache_scene(); + let t = Tick::from_seconds_f64(0.5); + let cache = crate::LayerCache::default(); + let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) }; + let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, t); + assert_eq!(cache.stats().entries, 0, "a called-off render kept layers"); + render_cached(&p, cid, t, Some(&cache)); + assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers"); +} + /// [`add_effect`] for a 200×100 layer. fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) { add_effect(p, l, id, [200.0, 100.0], vals); @@ -597,6 +640,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() { } } +/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs +/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect +/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the +/// probe is the x-span of the painted pixels, not their count. +#[test] +fn effects_on_text_reach_past_the_text_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let plain = render_frame(&p, cid, Tick::ZERO, 1.0); + let painted = plain.data.iter().filter(|px| px[3] > 0.5).count(); + add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]); + p.comp_mut(cid).unwrap().layers = vec![l]; + let tiled = render_frame(&p, cid, Tick::ZERO, 1.0); + let (mut lo, mut hi) = (u32::MAX, 0u32); + for y in 0..100 { + for x in 0..200 { + if tiled.get(x as i64, y as i64)[3] > 0.5 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + assert!( + painted > 50 && hi.saturating_sub(lo) > 100, + "CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}" + ); +} + +/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past +/// the painted rectangle used to fall off its edge, so the liquid trail never appeared. +#[test] +fn cc_smear_trails_text_past_its_bounds() { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0); + let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + add_effect_200( + &mut p, + &mut l, + "ec.distort.ccsmear", + &[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))], + ); + p.comp_mut(cid).unwrap().layers = vec![l]; + let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0)); + assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}"); +} + /// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the /// effect's own masks are in effect space too: Set Matte with a centred shape layer as the /// matte keeps the centre of a full-comp solid (#227). diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index f13dedd1..79796003 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize { (x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count() } -#[test] -fn continuously_rasterized_shape_stays_sharp_when_scaled_up() { - let run = |cr: bool| { - let mut p = project(); - let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); - let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); - let mut next = p.next_id; - let mut ids = Ids(&mut next); - let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); - let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); - let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); - p.next_id = next; - l.props.sub_mut("contents").unwrap().children.push(g.into()); - set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); +/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent +/// null's, with the Continuously Rasterize switch `cr`. +fn scaled_square(cr: bool, via_parent: bool) -> Image { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + l.switches.collapse = cr; + let mut c = comp.clone(); + if via_parent { + let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None); + set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); + set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); + set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0])); + l.parent = Some(null.id); + c.layers = vec![l, null]; + } else { set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); - l.switches.collapse = cr; - let mut c = comp; c.layers = vec![l]; - let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); - render_frame(&p, cid, Tick::ZERO, 1.0) - }; + } + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) +} + +/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up +/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased +/// edge, whether or not the Continuously Rasterize switch is on. +#[test] +fn shape_scaled_up_stays_sharp_without_the_switch() { // The 10 px square scaled 800% spans 60.3..140.3. - let soft = run(false); - let sharp = run(true); - let ws = edge_width(&soft, 100, 50, 75); - let wc = edge_width(&sharp, 100, 50, 75); - assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}"); - assert!(wc <= 1, "continuously rasterized edge: {wc}"); - assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01); + for cr in [false, true] { + for via_parent in [false, true] { + let img = scaled_square(cr, via_parent); + let w = edge_width(&img, 100, 50, 75); + assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide"); + assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01); + } + } } #[test] @@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() { assert_eq!(k, 1.0, "text at {pos:?}"); } } + +/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as +/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like +/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751). +#[test] +fn effects_on_a_scaled_shape_follow_its_scale() { + let run = |size: f64, scale: f64, blur: f64| { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]); + p.next_id = next; + fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.props.sub_mut("effects").unwrap().children.push(fx.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + let scaled = run(10.0, 800.0, 2.0); + let native = run(80.0, 100.0, 16.0); + let d = max_diff(&scaled, &native); + assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge"); + assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}"); +} diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index c9b8e56f..29e4d62c 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -7,7 +7,7 @@ use effectcraft_project::build; use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; -use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; +use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; fn setup(depth: BitDepth) -> (Project, ItemId, Comp) { let mut p = Project::default(); @@ -294,3 +294,48 @@ fn working_gamma_24_encodes_linear_footage_with_a_power_curve() { crate::color::Pipe::of(&s).key() }); } + +/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over +/// as the renderer asks ([`FloatCurve`]). +struct FloatFile([f32; 3]); +impl FootageSource for FloatFile { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.frame_with(item, f, t, None, FloatCurve::Srgb) + } + fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option> { + let c = self.0.map(|v| match float { + FloatCurve::Srgb => ColorSpace::Srgb.encode(v), + FloatCurve::Gamma24 => v.powf(1.0 / 2.4), + FloatCurve::Linear => v, + }); + Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0]))) + } +} + +/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as +/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on +/// top of the decode's own encoding. A working space ignores Working Gamma. +#[test] +fn float_footage_is_encoded_once_with_the_working_gamma() { + let render = |gamma: f64, ws: Option, linear_light: bool| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.working_space = ws; + let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) }; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = FloatFile([0.051, 0.18, 0.5]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3); + for linear_light in [false, true] { + let o = render(2.4, None, linear_light); + assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}"); + let o = render(2.2, None, linear_light); + assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}"); + let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light); + assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}"); + assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 7d7f7615..55dfd630 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -58,9 +58,21 @@ pub struct CharXf { /// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used. pub axes: [([u8; 4], f64); 4], pub n_axes: u8, + /// Front / Bevel / Side / Back Color of the extruded character (Advanced 3D), by + /// `effectcraft_model::extrude::Surface` index, and the selection each was set with. + pub surface: [Option<[f32; 4]>; 4], + pub surface_k: [f32; 4], } impl CharXf { + /// The surface colours of an extrusion painted `base` (the character's fill or stroke): + /// each animated surface colour blended over `base` by its selection. + pub fn surface_colors(&self, base: [f32; 4]) -> crate::shapes::SurfaceColors { + std::array::from_fn(|i| { + let (c, k) = (self.surface.get(i).copied().flatten()?, self.surface_k.get(i).copied().unwrap_or(0.0)); + Some(std::array::from_fn(|d| base[d] + (c[d] - base[d]) * k)) + }) + } /// The variable-font axis offsets that move the outline. pub fn axis_deltas(&self) -> Vec<(String, f32)> { self.axes[..self.n_axes as usize] @@ -79,6 +91,10 @@ impl CharXf { } } +/// Animator property match ids of Front / Bevel / Side / Back Color, in +/// `effectcraft_model::extrude::Surface::ALL` order. +pub const SURFACE_ANIM_PROPS: [&str; 4] = ["frontColor", "bevelColor", "sideColor", "backColor"]; + /// Animator property match id of a Variable Font Axes property (`axis_wght`). pub const AXIS_PREFIX: &str = "axis_"; @@ -115,6 +131,8 @@ impl Default for CharXf { blur: [0.0; 2], axes: [([0; 4], 0.0); 4], n_axes: 0, + surface: [None; 4], + surface_k: [0.0; 4], } } } @@ -149,9 +167,10 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false); let div = if units_index { n.max(1) as f64 } else { 100.0 }; Range { - start: ctx.f(layer, sel, "start", 0.0) / div, - end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div, - offset: ctx.f(layer, sel, "offset", 0.0) / div, + // Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%. + start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div, + end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div, + offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div, amount: af("amount", 100.0) / 100.0, shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)), smoothness: af("smoothness", 100.0) / 100.0, @@ -274,6 +293,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum()); let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum()); let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2()); + let surface = SURFACE_ANIM_PROPS.map(|m| props.get(m).map(|p| ctx.value(layer, p).as_color())); let axes: Vec<([u8; 4], f64)> = props .props() .filter_map(|p| { @@ -385,6 +405,12 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex for (tag, v) in &axes { c.add_axis(*tag, v * k); } + for ((s, sk), v) in c.surface.iter_mut().zip(&mut c.surface_k).zip(surface) { + if let Some(v) = v { + *s = Some(v); + *sk = (*sk + k as f32).clamp(0.0, 1.0); + } + } } } out diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 835f9d33..547c02d4 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -99,6 +99,30 @@ fn range_based_on_words_and_lines() { assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); } +#[test] +fn range_start_end_and_offset_are_capped() { + let (mut p, cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0)); + let a = animator(&mut p, &mut l, &["opacity"], &["range"]); + set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0)); + // End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing. + // Uncapped it would be -150%..150% and select everything. + set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0)); + set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0)); + p.comp_mut(cid).unwrap().layers.push(l); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]); + // Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing. + // Uncapped it would be -100%..150% and select everything. + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0)); + set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0)); + set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0)); + assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0)); + assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]); +} + #[test] fn selector_modes_and_randomize() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 4abbdddd..5b5ae956 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -543,10 +543,11 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option Vec<(Primitive, [f32; 4], Mat4)> { - // (paths, colour, mesh → layer, stacking level) - let mut groups: Vec<(Vec, [f32; 4], Mat4, usize)> = vec![]; + // (paths, colour, mesh → layer, stacking level, surface colours) + let mut groups: Vec<(Vec, [f32; 4], Mat4, usize, crate::shapes::SurfaceColors)> = vec![]; match layer.source { LayerSource::Text => { if let Some(tg) = crate::text::text_geom(ctx, layer) { @@ -561,13 +562,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; if g.apply_fill && g.fill[3] * op > 0.0 { let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op]; - groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top))); + groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c))); } if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 { let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) { let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op]; - groups.push((vec![region], c, g.m, usize::from(stroke_on_top))); + groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c))); } } } @@ -575,22 +576,32 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude } LayerSource::Shape => { if let Some(c) = layer.props.sub("contents") { - groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect(); + groups = + crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c, s))| (ps, c, Mat4::IDENTITY, k, s)).collect(); } } _ => {} } - groups - .into_iter() - .filter_map(|(paths, color, m, level)| { - let cs: Vec> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect(); - let outlines = model::extrude::group_contours(cs); - if outlines.is_empty() { - return None; + let mut out = vec![]; + for (paths, color, m, level, surfaces) in groups { + let cs: Vec> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect(); + let outlines = model::extrude::group_contours(cs); + if outlines.is_empty() { + continue; + } + if surfaces.iter().all(Option::is_none) { + out.push((model::extrude::extrude_stacked(&outlines, p, level), color, m)); + continue; + } + for (mesh, s) in model::extrude::extrude_surfaces_stacked(&outlines, p, level).into_iter().zip(surfaces) { + if mesh.indices.is_empty() { + continue; } - Some((model::extrude::extrude_stacked(&outlines, p, level), color, m)) - }) - .collect() + let c = s.map_or(color, |s| [s[0], s[1], s[2], color[3]]); + out.push((mesh, c, m)); + } + } + out } fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 67eb9ed6..d276a0f0 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -313,6 +313,85 @@ fn extruded_text_renders_as_a_mesh() { assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200); } +#[test] +fn shape_surface_items_colour_the_extruded_surfaces() { + let (mut p, cid) = project(200, 200); + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let surface = |ids: &mut build::Ids, m: &str, c: [f64; 4]| { + let mut g = build::shape_surface(ids, m).unwrap(); + g.children.push(build::shape_surface_prop(ids, "color").unwrap().into()); + if let Some(effectcraft_project::Node::Prop(pr)) = g.children.last_mut() { + pr.value = Value::Color(c); + } + g + }; + // A white square whose group colours its sides green; the layer's Contents colour every + // front blue and every side red, but the group's own Side wins. + let rect = build::shape_rect(ids, [80.0, 80.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]); + let side = surface(ids, "side", [0.0, 1.0, 0.0, 1.0]); + let group = build::shape_group(ids, "Group 1", vec![rect, fill, side]); + let front = surface(ids, "front", [0.0, 0.0, 1.0, 1.0]); + let outer_side = surface(ids, "side", [1.0, 0.0, 0.0, 1.0]); + let contents = l.props.sub_mut("contents").unwrap(); + contents.children.extend([group.into(), front.into(), outer_side.into()]); + l.props.children.push(build::extrusion_geometry_options(ids).into()); + set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); + set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); + }); + let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let l = &ctx.comp.layers[0]; + let params = scene::extrusion_params(&ctx, l).unwrap(); + let meshes = scene::extruded_meshes(&ctx, l, ¶ms); + // No bevel: Front, Side and Back (the Back keeps the fill's white). + let colours: Vec<[f32; 4]> = meshes.iter().map(|m| m.1).collect(); + assert_eq!(colours, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]); + assert!(meshes[0].0.normals.iter().all(|n| n[2] < -0.99), "front cap"); + assert!(meshes[1].0.normals.iter().all(|n| n[2].abs() < 1e-6), "walls"); + // Rendered (unlit: base colours) at an angle: the blue face and the green side both show. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/rotationY").unwrap().value = Value::Scalar(50.0); + let img = render(&p, cid); + let blue = img.data.iter().filter(|q| q[3] > 0.99 && q[2] > 0.9 && q[0] < 0.1 && q[1] < 0.1).count(); + let green = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9 && q[0] < 0.1 && q[2] < 0.1).count(); + assert!(blue > 200 && green > 50, "blue {blue}, green {green}"); +} + +#[test] +fn text_animators_colour_the_extruded_surfaces() { + let (mut p, cid) = project(200, 100); + add(&mut p, cid, LayerSource::Text, |l| { + l.switches.three_d = true; + let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() }; + set(l, "text/sourceText", Value::Text(Box::new(doc))); + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let props = ["sideColor", "bevelColor"].iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect(); + let anim = build::text_animator(ids, "Animator 1", props); + l.props.group_mut("text/animators").unwrap().children.push(anim.into()); + set(l, "text/animators/#1/properties/sideColor", Value::Color([0.0, 1.0, 0.0, 1.0])); + // Half the selection: the bevel colour blends half way over the fill. + set(l, "text/animators/#1/selectors/#1/advanced/amount", Value::Scalar(50.0)); + l.props.children.push(build::extrusion_geometry_options(ids).into()); + set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); + set(l, "geometryOptions/bevelStyle", Value::Enum(1)); + }); + let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let l = &ctx.comp.layers[0]; + let params = scene::extrusion_params(&ctx, l).unwrap(); + let meshes = scene::extruded_meshes(&ctx, l, ¶ms); + // Two characters × (Front, Bevel, Side, Back). + assert_eq!(meshes.len(), 8); + for m in meshes.chunks(4) { + assert_eq!(m[0].1, [1.0, 1.0, 1.0, 1.0], "front keeps the fill"); + assert_eq!(m[1].1, [1.0, 0.5, 0.5, 1.0], "bevel: red half way over white"); + assert_eq!(m[2].1, [0.5, 1.0, 0.5, 1.0], "side: green half way over white"); + assert_eq!(m[3].1, [1.0, 1.0, 1.0, 1.0], "back keeps the fill"); + } +} + // ---------------------------------------------------------------- motion blur, blend modes, mattes fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId { diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index b0a5f472..8c0f9d23 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -457,9 +457,29 @@ pub struct Views3D { /// Edited view cameras (views not present use their defaults). #[serde(default)] pub cams: BTreeMap, + /// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2); + /// panes not present show their default ([`Views3D::DEFAULT_PANES`]). + #[serde(default)] + pub panes: Vec, } impl Views3D { + /// Views the extra panes of a multi-view layout start with: Top, Front and Right. + pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right]; + /// The view of extra pane `i` (0 = the first beside the main view). + pub fn pane(&self, i: usize) -> View3D { + self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default() + } + /// Choose the view of extra pane `i` (ignored past the third). + pub fn set_pane(&mut self, i: usize, v: View3D) { + if i >= Self::DEFAULT_PANES.len() { + return; + } + while self.panes.len() <= i { + self.panes.push(Self::DEFAULT_PANES[self.panes.len()]); + } + self.panes[i] = v; + } pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam { self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h)) } diff --git a/crates/render/tests/motion_blur_probe.rs b/crates/render/tests/motion_blur_probe.rs new file mode 100644 index 00000000..df1e0cf7 --- /dev/null +++ b/crates/render/tests/motion_blur_probe.rs @@ -0,0 +1,112 @@ +//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames +//! (#535). Two bars move in opposite directions between discrete source frames; the gap +//! pixels between their old and new positions must fill in, and Vector Detail must matter. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project}; +use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; +use std::sync::Arc; + +// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source +// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find +// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is +// undefined (the aperture problem) and which no synthesizer can invent coverage for. +struct Clip; +impl FootageSource for Clip { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { + let k = f.frame_rate.frame_at(t).clamp(0, 19); + let dx = 12 * (k - 10); + let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]); + for y in 0..48 { + for x in 0..64 { + if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) { + let g = (x - (20 + dx)) as f32 / 12.0; + image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]); + } + } + } + Some(Arc::new(image)) + } +} + +fn scene(detail: Option) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = Footage { + path: "generated.mov".into(), + kind: FootageKind::Video, + width: 64, + height: 48, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_25, + duration: Tick::from_seconds_f64(0.8), + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + missing: false, + ..Default::default() + }; + let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage)); + let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None); + layer.switches.frame_blend = FrameBlend::Off; + layer.switches.motion_blur = false; + if let Some(detail) = detail { + let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap(); + let mut next = p.next_id; + let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]); + for (name, value) in [ + ("shutterControl", Value::Enum(1)), + ("shutterAngle", Value::Scalar(180.0)), + ("shutterSamples", Value::Scalar(16.0)), + ("vectorDetail", Value::Scalar(detail)), + ] { + effect.prop_mut(name).unwrap().value = value; + } + p.next_id = next; + layer.props.sub_mut("effects").unwrap().children.push(effect.into()); + } + p.comp_mut(cid).unwrap().layers.push(layer); + (p, cid) +} + +fn render(p: &Project, cid: ItemId, time: f64) -> Image { + Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time)) +} + +fn difference(a: &Image, b: &Image) -> f64 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum() +} + +#[test] +fn pixel_motion_blur_synthesizes_from_motion_vectors() { + let (plain, cid) = scene(None); + let still = render(&plain, cid, 0.4); + let average = average_of(&plain, cid); + let (low, low_id) = scene(Some(0.0)); + let (high, high_id) = scene(Some(100.0)); + let a = render(&low, low_id, 0.4); + let b = render(&high, high_id, 0.4); + assert!(difference(&b, &still) > 1.0, "the blur equals the still frame"); + assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions"); + assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::>()); + assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it"); +} + +fn average_of(p: &Project, cid: ItemId) -> Image { + let mut average = Image::new(64, 48); + for i in 0..16 { + let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6; + let frame = render(p, cid, time); + for (p, q) in average.data.iter_mut().zip(&frame.data) { + for c in 0..4 { + p[c] += q[c] / 16.0; + } + } + } + average +} diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index ef756107..10c635eb 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -18,6 +18,7 @@ effectcraft-render = { workspace = true } effectcraft-path = { workspace = true } serde_json = { workspace = true } boa_engine = { workspace = true } +web-time = { workspace = true } # On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time). [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -25,3 +26,4 @@ boa_engine = { workspace = true, features = ["js"] } [dev-dependencies] effectcraft-expr = { workspace = true } +effectcraft-psd = { workspace = true } diff --git a/crates/script/src/include.rs b/crates/script/src/include.rs new file mode 100644 index 00000000..3c397f53 --- /dev/null +++ b/crates/script/src/include.rs @@ -0,0 +1,173 @@ +//! ExtendScript's preprocessor directives, from the JavaScript Tools Guide's description of their +//! behaviour: `#include "file"` (and `//@include "file"`) inlines a file in place of the line, +//! resolved against the including file's folder, then the folders the latest `#includepath "a;b"` +//! (or `//@includepath`) named; `#target`, `#targetengine`, `#strict`, `#script` and `#engine` +//! are dropped. Each file is inlined once (which also breaks include cycles), nesting is limited +//! to [`MAX_DEPTH`], files and their total size are capped, and reads follow the scripts' file +//! gate ([`crate::scriptfile::check_read`]). Directives are recognised on lines of their own. +//! +//! Errors in the expanded code are mapped back to the file and line they came from. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; + +use crate::runtime::ScriptError; +use crate::scriptfile::{self, MAX_DEPTH, MAX_FILE_BYTES}; + +/// The most text a script and the files it includes may add up to. +const MAX_TOTAL_BYTES: usize = 4 * MAX_FILE_BYTES; + +/// A script with its includes inlined. +pub(crate) struct Expanded { + pub(crate) code: String, + /// Per line of `code`: the file (index into `files`) and 1-based line it came from. + lines: Vec<(usize, u32)>, + /// The script itself, then each included file. + files: Vec, +} + +impl Expanded { + /// Point an error in the expanded code at the file and line it came from. + pub(crate) fn locate(&self, e: &mut ScriptError) { + if self.files.first() != Some(&e.file) { + return; + } + let Some(&(file, line)) = e.line.and_then(|l| l.checked_sub(1)).and_then(|l| self.lines.get(l as usize)) else { return }; + if let Some(name) = self.files.get(file) { + e.file = name.clone(); + e.line = Some(line); + } + } +} + +enum Directive<'a> { + Include(&'a str), + IncludePath(&'a str), + Ignored, +} + +/// The preprocessor directive on `line`, if it is one. +fn directive(line: &str) -> Option> { + let t = line.trim(); + let (rest, comment) = match t.strip_prefix("//@") { + Some(r) => (r, true), + None => (t.strip_prefix('#')?, false), + }; + let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len()); + let (word, arg) = rest.split_at(end); + if !(arg.is_empty() || arg.starts_with(char::is_whitespace) || arg.starts_with(['"', '\''])) { + return None; + } + let arg = unquote(arg); + match word { + "include" => Some(Directive::Include(arg)), + "includepath" => Some(Directive::IncludePath(arg)), + // `//@target aftereffects` and friends are plain comments already. + "target" | "targetengine" | "strict" | "script" | "engine" if !comment => Some(Directive::Ignored), + _ => None, + } +} + +/// A directive's argument: `"x"`, `'x'` or `x`, with an optional trailing `;`. +fn unquote(arg: &str) -> &str { + let a = arg.trim(); + let a = a.strip_suffix(';').map_or(a, str::trim_end); + for q in ['"', '\''] { + if let Some(inner) = a.strip_prefix(q).and_then(|r| r.split(q).next()) { + return inner; + } + } + a +} + +struct Walk<'a> { + s: &'a Session, + /// The running script's folder: the file gate lets it load files below it. + root: Option, + /// The latest `#includepath`. + search: Vec, + seen: HashSet, + total: usize, + out: Expanded, +} + +impl Walk<'_> { + fn push(&mut self, line: &str, file: usize, n: usize) { + if !self.out.lines.is_empty() { + self.out.code.push('\n'); + } + self.out.code.push_str(line); + self.out.lines.push((file, u32::try_from(n + 1).unwrap_or(u32::MAX))); + } + + /// Inline `code` (file `file`, in folder `dir`), `depth` includes deep. + fn walk(&mut self, code: &str, file: usize, dir: Option<&Path>, depth: usize) -> Result<(), ScriptError> { + for (n, line) in code.split('\n').enumerate() { + let err = |w: &Self, message: String| ScriptError { + message, + line: Some(u32::try_from(n + 1).unwrap_or(u32::MAX)), + column: None, + file: w.out.files.get(file).cloned().unwrap_or_default(), + }; + match directive(line) { + None => self.push(line, file, n), + Some(Directive::Ignored) => self.push("", file, n), + Some(Directive::IncludePath(paths)) => { + self.search = + paths.split(';').map(str::trim).filter(|p| !p.is_empty()).filter_map(|p| scriptfile::resolve(self.s, p, dir, &[]).ok()).collect(); + self.push("", file, n); + } + Some(Directive::Include(name)) => { + if name.is_empty() { + return Err(err(self, "#include needs a file name".into())); + } + let path = scriptfile::resolve(self.s, name, dir, &self.search).map_err(|e| err(self, format!("#include {e}")))?; + if !self.seen.insert(path.clone()) { + // Already inlined (or the file including it): once is enough. + self.push("", file, n); + continue; + } + if depth >= MAX_DEPTH { + return Err(err(self, format!("#include \"{name}\": includes are nested too deeply (at most {MAX_DEPTH})"))); + } + scriptfile::check_read(self.s, &path, self.root.as_deref()).map_err(|e| err(self, e))?; + let text = scriptfile::read(self.s, &path).map_err(|e| err(self, e))?; + self.total = self.total.saturating_add(text.len()); + if self.total > MAX_TOTAL_BYTES { + return Err(err(self, format!("#include \"{name}\": the included files exceed {} MiB", MAX_TOTAL_BYTES >> 20))); + } + // The included lines take the directive's place. + self.out.files.push(path.to_string_lossy().to_string()); + let idx = self.out.files.len().saturating_sub(1); + self.walk(&text, idx, path.parent(), depth + 1)?; + } + } + } + Ok(()) + } +} + +/// `code` (the script `name`) with its directives applied, or `None` when it has none. +pub(crate) fn expand(s: &Session, code: &str, name: &str) -> Result, ScriptError> { + if !code.split('\n').any(|l| directive(l).is_some()) { + return Ok(None); + } + let root = scriptfile::script_dir(s, name); + let mut w = Walk { + s, + root: root.clone(), + search: vec![], + seen: HashSet::new(), + total: code.len(), + out: Expanded { code: String::with_capacity(code.len()), lines: vec![], files: vec![if name.is_empty() { "script" } else { name }.to_string()] }, + }; + if root.is_some() { + w.seen.insert(crate::runtime::normalize(name)); + } + // A script without a folder (the Script Console, code run by name) looks in the project's. + let dir = root.or_else(|| crate::runtime::project_dir(s).map(|d| crate::runtime::normalize(&d))); + w.walk(code, 0, dir.as_deref(), 0)?; + Ok(Some(w.out)) +} diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index d1c9568c..067b85fd 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -14,13 +14,14 @@ //! ``` //! //! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups, -//! `executeCommand`/`findMenuCommandId`, `scheduleTask`, `settings` kept in the settings +//! `executeCommand`/`findMenuCommandId`, `scheduleTask` (one-shot and repeating, see +//! [`run_tasks`]), `settings` kept in the settings //! folder), `Project`, `ItemCollection`, //! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/ //! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, //! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`, //! `MarkerValue`, `RenderQueue`/`RenderQueueItem`/`OutputModule`, `ImportOptions`, `File`/ -//! `Folder`, `$`, `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, +//! `Folder`, `$` (with `evalFile`), `BridgeTalk` (host identification), `alert`/`writeLn`, and the enums (`BlendingMode`, `KeyframeInterpolationType`, //! `TrackMatteType`, `LightType`, `ParagraphJustification`, `PropertyValueType`…). //! * **Edits are engine commands**: every mutating call runs a command through //! [`Session::execute`](effectcraft_engine::Session::execute), so it is undoable, journaled @@ -53,15 +54,19 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![recursion_limit = "256"] +mod include; pub mod matchnames; mod model; mod runtime; +mod scriptfile; mod socket; +mod tasks; mod ui; use effectcraft_engine::{ScriptRequest, Session}; pub use runtime::{Outcome, ScriptError, run}; +pub use tasks::{MAX_TASKS, MIN_REPEAT_MS, run_tasks}; pub use ui::dispatch_ui; /// The [`effectcraft_engine::ScriptRunner`] this crate provides. @@ -73,6 +78,7 @@ pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value { pub fn install(s: &mut Session) { s.script = Some(runner); s.script_ui.dispatch = Some(dispatch_ui); + s.script_ui.tasks = Some(run_tasks); } /// Run `code` as a one-off script named `name`. @@ -83,10 +89,22 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_callable; +#[cfg(test)] mod tests_ease_presets; #[cfg(test)] +mod tests_include; +#[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_tasks; +#[cfg(test)] mod tests_ui; #[cfg(test)] mod tests_ui_more; + +// #760: `$.evalFile` and `BridgeTalk`. +#[cfg(test)] +mod tests_evalfile; +#[cfg(test)] +mod tests_ui_mouse; diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index a48a36a0..d8a8f3aa 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -61,8 +61,24 @@ const TEXT: &[(&str, &str)] = &[ ("animators", "ADBE Text Animators"), ]; +// Match names of the six properties below Text ▸ Path Options, as named by +// After Effects' scripting API (rather than EffectCraft's internal ids). +const TEXT_PATH_OPTIONS: &[(&str, &str)] = &[ + ("path", "ADBE Text Path"), + ("reversePath", "ADBE Text Reverse Path"), + ("perpendicular", "ADBE Text Perpendicular To Path"), + ("forceAlignment", "ADBE Text Force Align Path"), + ("firstMargin", "ADBE Text First Margin"), + ("lastMargin", "ADBE Text Last Margin"), +]; + const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")]; +const TEXT_SELECTORS: &[(&str, &str)] = + &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; + +const TEXT_RANGE_SELECTOR: &[(&str, &str)] = &[("start", "ADBE Text Percent Start"), ("end", "ADBE Text Percent End"), ("offset", "ADBE Text Percent Offset")]; + const CAMERA: &[(&str, &str)] = &[ ("zoom", "ADBE Camera Zoom"), ("dof", "ADBE Camera Depth of Field"), @@ -411,7 +427,10 @@ pub fn node(chain: &[&Node]) -> String { } } "text" if chain.len() == 2 => find(TEXT, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "pathOptions" => find(TEXT_PATH_OPTIONS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "animators" => "ADBE Text Animator".into(), + "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), + "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), "lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own), @@ -458,4 +477,15 @@ mod tests { assert_eq!(shape_kind("ADBE Vector Graphic - Fill"), Some("fill")); assert_eq!(shape_kind("nope"), None); } + + #[test] + fn text_range_selector_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, selectors, range, start, end, offset) = + (n("text"), n("animators"), n("animator"), n("selectors"), n("rangeSelector"), n("start"), n("end"), n("offset")); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range]), "ADBE Text Selector"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &start]), "ADBE Text Percent Start"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &end]), "ADBE Text Percent End"); + assert_eq!(node(&[&text, &animators, &animator, &selectors, &range, &offset]), "ADBE Text Percent Offset"); + } } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c..9daac6ab 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -6,8 +6,16 @@ var __ecGlobal = this; +// The scripting host keeps wrappers stable for each project item and comp/layer id. +// Clear them when loading a different document, whose IDs may be reused. +var __itemCache = Object.create(null), __layerCache = Object.create(null); +function __clearIdentityCache() { + __itemCache = Object.create(null); + __layerCache = Object.create(null); +} function __call(id, p) { var r = __exec(id, JSON.stringify(p || {})); + if (id === "file.newProject" || id === "file.open" || id === "file.revert") __clearIdentityCache(); return r === undefined || r === "" ? null : JSON.parse(r); } function __get(kind, a) { @@ -27,6 +35,25 @@ function __str(v) { return String(v); } +// ExtendScript includes the ES3 String.substr(start, length) method, which Boa +// does not currently provide. Follow its legacy length (not end-index) semantics. +if (typeof String.prototype.substr !== "function") { + Object.defineProperty(String.prototype, "substr", { + value: function (start, length) { + if (this === null || this === undefined) throw new TypeError("String.substr called on null or undefined"); + var text = String(this), n = text.length; + var from = Number(start); + if (isNaN(from)) from = 0; + // ToIntegerOrInfinity, then clamp positive or negative offsets. + from = from < 0 ? Math.max(n + Math.ceil(from), 0) : Math.min(Math.floor(from), n); + var count = length === undefined ? n - from : Number(length); + if (isNaN(count) || count <= 0) return ""; + return text.slice(from, from + count); + }, + writable: true, configurable: true + }); +} + // ------------------------------------------------------------------ enums function __enum(name, keys, base) { @@ -67,6 +94,9 @@ var MaskMode = __enum("MaskMode", ["NONE", "ADD", "SUBTRACT", "INTERSECT", "LIGH var RQItemStatus = __enum("RQItemStatus", ["WILL_CONTINUE", "NEEDS_OUTPUT", "UNQUEUED", "QUEUED", "RENDERING", "USER_STOPPED", "ERR_STOPPED", "DONE"], 3012); var CloseOptions = __enum("CloseOptions", ["DO_NOT_SAVE_CHANGES", "PROMPT_TO_SAVE_CHANGES", "SAVE_CHANGES"], 1212); var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "COMP", "PROJECT"], 3812); +// Accept the longer names used by some ExtendScript hosts. +ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS; +ImportAsType.COMPOSITION = ImportAsType.COMP; var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); @@ -111,9 +141,50 @@ var $ = { get os() { return "EffectCraft"; }, get version() { return "EffectCraft scripting"; }, get locale() { return "en_US"; }, - evalFile: function () { throw __err("$.evalFile is not supported: scripts can't run scripts"); }, + // Evaluates the file in the global scope and returns its last value (`timeout` is ignored). + evalFile: function (file, timeout) { return __evalFile(__str(file)); }, }; +// ------------------------------------------------------------------ BridgeTalk + +// Which application a script runs in, for scripts and frameworks that ask during initialisation. +// EffectCraft names itself rather than posing as another application; messages to other +// applications aren't supported. +function BridgeTalk() { + if (!(this instanceof BridgeTalk)) return new BridgeTalk(); + this.target = ""; + this.sender = BridgeTalk.appSpecifier; + this.body = ""; + this.type = "ExtendScript"; + this.headers = {}; + this.timeout = 0; + this.onResult = null; + this.onError = null; + this.onReceived = null; + this.onTimeout = null; +} +BridgeTalk.prototype.send = function () { + throw __err("BridgeTalk: sending messages to other applications is not supported in EffectCraft"); +}; +BridgeTalk.prototype.sendResult = BridgeTalk.prototype.send; +BridgeTalk.prototype.toString = function () { return "[object BridgeTalk]"; }; +BridgeTalk.appName = "effectcraft"; +Object.defineProperty(BridgeTalk, "appVersion", { get: function () { return __get("app").buildName; } }); +Object.defineProperty(BridgeTalk, "appSpecifier", { get: function () { return "effectcraft-" + BridgeTalk.appVersion; } }); +BridgeTalk.appLocale = "en_US"; +BridgeTalk.appInstance = ""; +BridgeTalk.appStatus = "idle"; +BridgeTalk.onReceive = null; +BridgeTalk.__isSelf = function (spec) { return String(spec === undefined || spec === null ? "" : spec).split("-")[0].toLowerCase() === "effectcraft"; }; +BridgeTalk.isRunning = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.launch = function (spec) { return BridgeTalk.__isSelf(spec); }; +BridgeTalk.getSpecifier = function (name) { return BridgeTalk.__isSelf(name) ? BridgeTalk.appSpecifier : null; }; +BridgeTalk.getDisplayName = function (spec) { return BridgeTalk.__isSelf(spec) ? "EffectCraft" : null; }; +BridgeTalk.getTargets = function () { return [BridgeTalk.appName]; }; +BridgeTalk.getStatus = function (spec) { return spec === undefined || BridgeTalk.__isSelf(spec) ? "IDLE" : "ISNOTINSTALLED"; }; +BridgeTalk.bringToFront = function () {}; +BridgeTalk.pump = function () { return false; }; + // ------------------------------------------------------------------ File / Folder function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } @@ -138,7 +209,7 @@ File.prototype = { get path() { var p = this.__path.split(/[\/\\]/); p.pop(); return p.join("/"); }, get parent() { return new Folder(this.path); }, get exists() { return JSON.parse(__file("exists", this.__path)) === true; }, - get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file("read", this.__path)).length : 0); }, + get length() { return this.__buf !== null ? this.__buf.length : (this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)).length : 0); }, get eof() { return this.__buf === null || this.__pos >= this.__buf.length; }, toString: function () { return this.__path; }, open: function (mode) { @@ -152,9 +223,9 @@ File.prototype = { this.__buf = ""; return true; } - this.__buf = JSON.parse(__file("read", this.__path)); + this.__buf = JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)); } else if (mode === "a") { - this.__buf = this.exists ? JSON.parse(__file("read", this.__path)) : ""; + this.__buf = this.exists ? JSON.parse(__file(this.encoding.toUpperCase() === "BINARY" ? "readBinary" : "read", this.__path)) : ""; this.__pos = this.__buf.length; } else { // Check the gate now, like After Effects refusing to open a file for writing. @@ -191,18 +262,14 @@ File.prototype = { tell: function () { return this.__pos; }, close: function () { if ((this.__mode === "w" || this.__mode === "a" || this.__mode === "e") && this.__buf !== null) { - __file("write", this.__path, this.__buf); + __file(this.encoding.toUpperCase() === "BINARY" ? "writeBinary" : "write", this.__path, this.__buf); } this.__buf = null; this.__mode = null; return true; }, remove: function () { return JSON.parse(__file("remove", this.__path)); }, - copy: function (to) { - var data = JSON.parse(__file("read", this.__path)); - __file("write", __str(to), data); - return true; - }, + copy: function (to) { return JSON.parse(__file("copy", this.__path, __str(to))); }, rename: function () { throw __err("File.rename is not supported"); }, execute: function () { return JSON.parse(__file("execute", this.__path)); }, openDlg: function () { return null; }, @@ -340,6 +407,25 @@ var __attr = { }, }; +// Layers, property groups and properties are callable, like After Effects: `layer("Effects")`, +// `fx(1)` and `effect("Slider Control")("Slider")` are `.property(...)`. A Proxy is callable +// only around a function, so the object's state moves onto an arrow function (no `prototype`; +// its own `name` and `length` are removed so the object's getters show) with the object's +// prototype; `instanceof` and attribute access are unchanged. +function __callable(o) { + var f = () => undefined; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Object.getPrototypeOf(o)); + var keys = Object.keys(o); + for (var i = 0; i < keys.length; i++) f[keys[i]] = o[keys[i]]; + var self = new Proxy(f, { + get: __attr.get, + apply: function (t, thisArg, args) { return self.property(args[0]); }, + }); + return self; +} + // 1-based collections with `[n]` access. var __coll = { get: function (t, k, r) { @@ -604,7 +690,7 @@ function PropertyGroup(layer, uid) { this.__layer = layer; this.__uid = uid; this.__kind = "PropertyGroup"; - return new Proxy(this, __attr); + return __callable(this); } PropertyGroup.prototype = Object.create(PropertyBase.prototype); PropertyGroup.prototype.constructor = PropertyGroup; @@ -647,7 +733,7 @@ function MaskPropertyGroup(layer, uid) { this.__layer = layer; this.__uid = uid; this.__kind = "MaskPropertyGroup"; - return new Proxy(this, __attr); + return __callable(this); } MaskPropertyGroup.prototype = Object.create(PropertyGroup.prototype); MaskPropertyGroup.prototype.constructor = MaskPropertyGroup; @@ -679,7 +765,7 @@ function Property(layer, uid) { this.__layer = layer; this.__uid = uid; this.__kind = "Property"; - return new Proxy(this, __attr); + return __callable(this); } Property.prototype = Object.create(PropertyBase.prototype); Property.prototype.constructor = Property; @@ -843,6 +929,19 @@ Property.prototype.property = function () { return null; }; return __call(id, params); }; P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); }; + // EffectCraft extension: remove an inclusive range with one engine edit instead of + // cloning the project for every removed key. Indices are one-based as in removeKey. + P.removeKeys = function (first, last) { + var keys = this.__keys(); + first = __num(first, "first key"); + last = __num(last, "last key"); + if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first || last > keys.length) + throw __err("removeKeys: key range is out of bounds"); + var targets = []; + for (var i = first; i <= last; i++) + targets.push({ layer: this.__layer.__id, prop: this.__uid, index: i - 1 }); + return __call("keys.delete", { comp: this.__layer.__comp, __keys: targets }); + }; P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; }; P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; }; // Values that don't interpolate (checkboxes, popups, text) only hold. @@ -949,7 +1048,7 @@ MarkerProperty.prototype = { t = __num(t, "time"); if (!(mv instanceof MarkerValue)) throw __err("value must be a MarkerValue"); var p = this.__p({ time: t, duration: mv.duration || 0, comment: mv.comment, chapter: mv.chapter, url: mv.url, frameTarget: mv.frameTarget, protected: !!mv.protectedRegion, label: mv.label || 0 }); - p.cuePoint = mv.cuePointName ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; + p.cuePoint = (mv.cuePointName || mv.__params.length) ? { name: mv.cuePointName, navigation: !mv.eventCuePoint, params: mv.__params } : null; var l = this.__list(), at = -1; for (var i = 0; i < l.length; i++) if (Math.abs(l[i].time - t) < 1e-6) at = i; if (at >= 0) p.index = at; @@ -965,14 +1064,18 @@ MarkerProperty.prototype = { function __layer(compId, id) { if (id === null || id === undefined) return null; - var i = __get("layer", { comp: compId, layer: id }); + var key = compId + ":" + id; + if (Object.prototype.hasOwnProperty.call(__layerCache, key)) return __layerCache[key]; + var i = __get("layer", { comp: compId, layer: id }), layer; switch (i.kind) { - case "text": return new TextLayer(compId, id); - case "shape": return new ShapeLayer(compId, id); - case "camera": return new CameraLayer(compId, id); - case "light": return new LightLayer(compId, id); - default: return new AVLayer(compId, id); + case "text": layer = new TextLayer(compId, id); break; + case "shape": layer = new ShapeLayer(compId, id); break; + case "camera": layer = new CameraLayer(compId, id); break; + case "light": layer = new LightLayer(compId, id); break; + default: layer = new AVLayer(compId, id); } + __layerCache[key] = layer; + return layer; } function Layer() {} @@ -981,7 +1084,7 @@ Layer.prototype = { this.__comp = comp; this.__id = id; this.__kind = kind; - return new Proxy(this, __attr); + return __callable(this); }, __info: function () { return __get("layer", { comp: this.__comp, layer: this.__id }); }, __p: function (o) { @@ -1008,7 +1111,7 @@ Layer.prototype = { set name(v) { __call("layer.rename", this.__p1({ name: String(v) })); }, get index() { return this.__info().index; }, get matchName() { return this.__info().matchName; }, - get containingComp() { return new CompItem(this.__comp); }, + get containingComp() { return __item(this.__comp); }, get propertyDepth() { return 0; }, get propertyType() { return PropertyType.INDEXED_GROUP; }, get parentProperty() { return null; }, @@ -1050,6 +1153,7 @@ Layer.prototype = { __call("edit.label", this.__p({ label: n })); }, get comment() { return this.__info().comment; }, + set comment(v) { __call("layer.setComment", this.__p({ comment: String(v) })); }, get selected() { return this.__info().selected; }, set selected(v) { var sel = this.containingComp.__info().selectedLayers; @@ -1117,6 +1221,11 @@ AVLayer.prototype.constructor = AVLayer; var P = AVLayer.prototype; function def(name, get, set) { Object.defineProperty(P, name, { get: get, set: set }); } def("source", function () { return __item(this.__info().source); }); + // After Effects exposes the most common Transform properties directly on AVLayer. + // Return the original Property object, not its current value: scripts can keyframe it. + ["anchorPoint", "position", "scale", "rotation", "opacity"].forEach(function (name) { + def(name, function () { return this.transform[name]; }); + }); def("width", function () { return this.__info().width; }); def("height", function () { return this.__info().height; }); def("threeDLayer", function () { return this.__info().threeDLayer; }, function (v) { this.__switch("threeD", v); }); @@ -1273,7 +1382,7 @@ LayerCollection.prototype = { var ids = this.__ids(), sel = []; for (var i = 0; i < indices.length; i++) sel.push(ids[indices[i] - 1]); var r = __call("layer.precompose", { comp: this.__comp, layers: sel, name: String(name), mode: moveAllAttributes === false ? "leave" : "move" }); - return new CompItem(r.comp); + return __item(r.comp); }, byName: function (name) { var ids = this.__ids(); @@ -1321,13 +1430,20 @@ ItemCollection.prototype = { function __item(id) { if (id === null || id === undefined) return null; - if (id === 0) return new FolderItem(0); - var i = __get("item", { id: id }); - switch (i.kind) { - case "comp": return new CompItem(id); - case "folder": return new FolderItem(id); - default: return new FootageItem(id); + if (Object.prototype.hasOwnProperty.call(__itemCache, id)) return __itemCache[id]; + var item; + if (id === 0) { + item = new FolderItem(0); + } else { + var i = __get("item", { id: id }); + switch (i.kind) { + case "comp": item = new CompItem(id); break; + case "folder": item = new FolderItem(id); break; + default: item = new FootageItem(id); + } } + __itemCache[id] = item; + return item; } function Item() {} @@ -1357,7 +1473,7 @@ Item.prototype = { __call("project.select", { items: selection.filter(function (item) { return item !== id; }) }); } }, - get usedIn() { return this.__info().usedIn.map(function (c) { return new CompItem(c); }); }, + get usedIn() { return this.__info().usedIn.map(function (c) { return __item(c); }); }, remove: function () { __call("project.delete", { items: [this.__id] }); }, }; @@ -1584,7 +1700,7 @@ RenderQueueItem.prototype = { for (var i = 0; i < l.length; i++) if (l[i].id === this.__id) return l[i]; throw __err("the render queue item no longer exists"); }, - get comp() { return new CompItem(this.__info().comp); }, + get comp() { return __item(this.__info().comp); }, get status() { var s = this.__info().statusLabel; for (var k in __rqStatus) if (s.indexOf(k) === 0) return __rqStatus[k]; @@ -1674,7 +1790,11 @@ function ImportOptions(file) { this.rangeStart = 0; this.rangeEnd = 0; } -ImportOptions.prototype.canImportAs = function (t) { return t === ImportAsType.FOOTAGE; }; +ImportOptions.prototype.canImportAs = function (t) { + if (t === ImportAsType.FOOTAGE) return true; + return (t === ImportAsType.COMP || t === ImportAsType.COMP_CROPPED_LAYERS) && + /\.(psd|psb|pdf|ai|eps)$/i.test(__str(this.file)); +}; function Project() {} Project.prototype = { @@ -1682,7 +1802,7 @@ Project.prototype = { __info: function () { return __get("project"); }, get items() { return new ItemCollection(0); }, get numItems() { return this.__info().items.length; }, - get rootFolder() { return new FolderItem(0); }, + get rootFolder() { return __item(0); }, get activeItem() { return __item(this.__info().active); }, get selection() { return this.__info().selection.map(__item); }, get file() { var p = this.__info().path; return p ? new File(p) : null; }, @@ -1728,10 +1848,19 @@ Project.prototype = { if (!f) throw __err("importFile(): no file"); var args = { paths: [__str(f)] }; // After Effects imports just the picked file unless ImportOptions.sequence is set. - if (opts instanceof ImportOptions) args.sequence = !!opts.sequence; + if (opts instanceof ImportOptions) { + args.sequence = !!opts.sequence; + switch (opts.importAs) { + case ImportAsType.FOOTAGE: args.importAs = "footage"; break; + case ImportAsType.COMP: args.importAs = "composition"; break; + case ImportAsType.COMP_CROPPED_LAYERS: args.importAs = "compositionLayerSizes"; break; + default: throw __err("importFile(): unsupported ImportOptions.importAs value " + opts.importAs); + } + } var r = __call("file.import", args); if (r.errors && r.errors.length) throw __err(r.errors.join("; ")); - return __item(r.items[0]); + // Composition imports create several footage items, but callers need the comp. + return __item(r.comps && r.comps.length ? r.comps[0] : r.items[0]); }, importFileWithDialog: function () { return null; }, importPlaceholder: function (name, w, h, rate, dur) { @@ -1798,13 +1927,18 @@ var app = { beginSuppressDialogs: function () {}, endSuppressDialogs: function () {}, purge: function () { __call("edit.purge", { what: "all" }); }, - findMenuCommandId: function (name) { return __get("menuCommandId", { name: String(name) }); }, + findMenuCommandId: function (name) { + // Well-known After Effects ids must not shift when EffectCraft registers a command. + if (String(name) === "Undo") return 16; + return __get("menuCommandId", { name: String(name) }); + }, executeCommand: function (id) { + if (id === 16) return __call("edit.undo", {}); var cmd = typeof id === "number" ? __get("commandAt", { index: id }) : String(id); if (!cmd) throw __err("Unknown command id " + id); return __call(cmd, {}); }, - scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), !!repeat); }, + scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), Number(delay), !!repeat); }, cancelTask: function (id) { __task("cancel", id); }, setMemoryUsageLimits: function () {}, setMultiFrameRenderingConfig: function () {}, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 712999ee..c7559856 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -6,6 +6,7 @@ use std::mem::ManuallyDrop; use std::sync::Arc; use boa_engine::{Context, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use effectcraft_engine::history::History; use effectcraft_engine::project::Project; use effectcraft_engine::{ScriptRequest, Session}; use serde_json::{Value as J, json}; @@ -63,20 +64,23 @@ impl Outcome { pub(crate) struct Active { pub(crate) session: Session, pub(crate) output: Vec, - /// Open undo groups: (name, project snapshot when the outermost group began). - pub(crate) groups: Vec<(String, Arc)>, - /// `app.scheduleTask` queue: (id, code, repeat). - pub(crate) tasks: Vec<(u32, String, bool)>, - pub(crate) next_task: u32, + /// Open groups: name, project snapshot, original bounded history and saved-project + /// identity. The history snapshot preserves earlier steps if long groups hit the limit. + pub(crate) groups: Vec<(String, Arc, History, Arc)>, pub(crate) name: String, + /// The script context running (its `app.scheduleTask` tasks are kept under this id, see + /// [`crate::tasks`]). + pub(crate) host: u32, /// Running on a script-host thread: modal dialogs hand the session back to the caller /// through this link while they wait. pub(crate) link: Option, + /// The files `$.evalFile` is evaluating, outermost first. + pub(crate) loading: Vec, } impl Active { - pub(crate) fn new(session: Session, name: &str) -> Active { - Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None } + pub(crate) fn new(session: Session, name: &str, host: u32) -> Active { + Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None, loading: vec![] } } } @@ -238,12 +242,18 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let snap = a.session.project.clone(); - a.groups.push((if name.is_empty() || name == "undefined" { "Script".into() } else { name }, snap)); + a.groups.push(( + if name.is_empty() || name == "undefined" { "Script".into() } else { name }, + snap, + a.session.history.clone(), + a.session.saved_project.clone(), + )); } _ => { if a.groups.len() == 1 { - let (name, snap) = a.groups.pop().unwrap_or_default(); - collapse(&mut a.session, &name, &snap); + if let Some((name, snap, prior, saved)) = a.groups.pop() { + collapse(&mut a.session, &name, &snap, &prior, &saved); + } } else { a.groups.pop(); } @@ -255,49 +265,61 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult) { +/// Fold every command in a group into a single bounded-history entry, even if the +/// oldest intermediate undo state was trimmed before the group ended. +pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc, prior: &History, saved: &Arc) { let h = &mut s.history; - let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { - // Nothing was recorded (or history was reset by New / Open Project). - return; - }; - let first = h.undo[start].1.clone(); - h.undo.truncate(start); - h.undo.push((name.to_string(), first)); - h.merge_key = None; + if let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) { + // The group is short enough that its opening snapshot is still in history. + let first = h.undo[start].1.clone(); + h.undo.truncate(start); + h.undo.push((name.to_string(), first)); + h.merge_key = None; + } else if !Arc::ptr_eq(&s.project, snap) && Arc::ptr_eq(&s.saved_project, saved) && !h.undo.is_empty() { + // The group exceeded Levels of Undo. Restore the pre-group history (including + // older steps and redo branches) and record one step for the *whole* group. + // A New / Open Project changes saved_project; never resurrect the old history. + *h = prior.clone(); + h.record(name, snap.clone(), s.prefs.general.undo_levels.max(1) as usize); + } } -/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`. +/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see +/// [`crate::tasks`]). fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; if op == "cancel" { - let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32; - let _ = with_active(|a| { - a.tasks.retain(|t| t.0 != id); - Ok(()) - }); + let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)?; + if id.is_finite() && id >= 0.0 && id <= f64::from(u32::MAX) { + let _ = with_active(|a| { + crate::tasks::cancel(a.host, id as u32); + Ok(()) + }); + } return Ok(JsValue::undefined()); } let code = arg_string(args, 1, ctx)?; - let repeat = args.get(2).cloned().unwrap_or_default().to_boolean(); + let delay = args.get(2).cloned().unwrap_or_default().to_number(ctx)?; + let repeat = args.get(3).cloned().unwrap_or_default().to_boolean(); + let now = web_time::Instant::now(); let id = with_active(|a| { - a.next_task += 1; - let id = a.next_task; - a.tasks.push((id, code, repeat)); + let id = crate::tasks::schedule(a.host, &a.name, a.session.script_ui.project_epoch, code, delay, repeat, now)?; + if !a.session.script_ui.task_hosts.contains(&a.host) { + a.session.script_ui.task_hosts.push(a.host); + } Ok(id) }) .map_err(throw)?; Ok(JsValue::from(id)) } -fn project_dir(s: &Session) -> Option { +pub(crate) fn project_dir(s: &Session) -> Option { let p = s.path.as_deref()?; std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string()) } /// Lexically normalized absolute path (`a/./b/../c` → `a/c`). -fn normalize(p: &str) -> std::path::PathBuf { +pub(crate) fn normalize(p: &str) -> std::path::PathBuf { use std::path::{Component, Path, PathBuf}; let path = Path::new(p); let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) }; @@ -338,8 +360,14 @@ fn check_access(s: &Session, path: &str, write: bool) -> Result Result, String> { + data.chars().map(|c| u8::try_from(c as u32).map_err(|_| format!("BINARY encoding cannot represent U+{:04X}", c as u32))).collect() +} + +/// `__file(op, path, data)`: text and one-byte-per-character BINARY reads/writes, +/// byte-for-byte copy, plus file and folder operations. fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let op = arg_string(args, 0, ctx)?; let path = arg_string(args, 1, ctx)?; @@ -358,19 +386,36 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + "read" | "readBinary" => { let p = check_access(s, &path, false)?; let bytes = s.services.read_file(&p.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; - Ok(json!(String::from_utf8_lossy(&bytes))) + if op == "readBinary" { + // Each byte becomes a single JS character (U+0000..U+00FF). + Ok(json!(bytes.into_iter().map(char::from).collect::())) + } else { + Ok(json!(String::from_utf8_lossy(&bytes))) + } } - "write" | "append" => { + "write" | "writeBinary" | "append" => { let p = check_access(s, &path, true)?; let ps = p.to_string_lossy().to_string(); let mut bytes = if op == "append" { s.services.read_file(&ps).unwrap_or_default() } else { vec![] }; - bytes.extend_from_slice(data.as_bytes()); + if op == "writeBinary" { + bytes.extend(binary_bytes(&data)?); + } else { + bytes.extend_from_slice(data.as_bytes()); + } s.services.write_file(&ps, &bytes).map_err(|e| format!("Unable to write {path}: {e}"))?; Ok(json!(bytes.len())) } + "copy" => { + let src = check_access(s, &path, false)?; + let dst = check_access(s, &data, true)?; + // Copy the original bytes, not UTF-8-decoded script text. + let bytes = s.services.read_file(&src.to_string_lossy()).map_err(|e| format!("Unable to read {path}: {e}"))?; + s.services.write_file(&dst.to_string_lossy(), &bytes).map_err(|e| format!("Unable to write {data}: {e}"))?; + Ok(json!(true)) + } "list" => { let p = check_access(s, &path, false)?; let mut names: Vec = std::fs::read_dir(&p) @@ -405,18 +450,58 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { + use crate::scriptfile::{self, MAX_DEPTH}; + let name = arg_string(args, 0, ctx)?; + if name.is_empty() || name == "undefined" { + return Err(throw("$.evalFile needs a file")); + } + let (path, text) = with_active(|a| { + if a.loading.len() >= MAX_DEPTH { + return Err(format!("$.evalFile(\"{name}\"): files are nested too deeply (at most {MAX_DEPTH})")); + } + let s = &a.session; + let root = scriptfile::script_dir(s, &a.name); + let dir = a + .loading + .last() + .and_then(|p| p.parent().map(std::path::Path::to_path_buf)) + .or_else(|| root.clone()) + .or_else(|| project_dir(s).map(|d| normalize(&d))); + let path = scriptfile::resolve(s, &name, dir.as_deref(), &[]).map_err(|e| format!("$.evalFile {e}"))?; + scriptfile::check_read(s, &path, root.as_deref())?; + let text = scriptfile::read(s, &path)?; + check_nesting(&text)?; + a.loading.push(path.clone()); + Ok((path, text)) + }) + .map_err(throw)?; + let r = ctx.eval(Source::from_bytes(text.as_bytes()).with_path(&path)); + let _ = with_active(|a| { + a.loading.pop(); + Ok(()) + }); + r +} + pub(crate) fn new_context() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 10] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 11] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), - (js_string!("__task"), 3, native_task), + (js_string!("__task"), 4, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__evalFile"), 2, native_eval_file), (js_string!("__sock"), 3, crate::socket::native_sock), (js_string!("__uiNewId"), 0, crate::ui::native_new_id), (js_string!("__uiModal"), 1, crate::ui::native_modal), @@ -543,8 +628,8 @@ impl Drop for Restore<'_> { /// and take the output so far. pub(crate) fn settle() -> Vec { with_active(|a| { - if let Some((name, snap)) = a.groups.first().cloned() { - collapse(&mut a.session, &name, &snap); + if let Some((name, snap, prior, saved)) = a.groups.first().cloned() { + collapse(&mut a.session, &name, &snap, &prior, &saved); a.groups.clear(); } a.session.sanitize_state(); @@ -553,14 +638,11 @@ pub(crate) fn settle() -> Vec { .unwrap_or_default() } -/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`). -pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result { +/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`; +/// repeating ones run later, see [`crate::tasks`]). +pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str, host: u32) -> Result { let mut r = eval_in(ctx, code, file); - let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); - for (_, code, repeat) in tasks { - if repeat { - continue; - } + for code in crate::tasks::take_one_shots(host) { if let Err(e) = eval_in(ctx, &code, "scheduled task") && r.is_ok() { @@ -587,7 +669,23 @@ pub(crate) fn outcome(output: Vec, result: Result) -> Ou /// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host /// alive for their handlers. The Script Console (and the web build) run inline, where dialogs /// don't block. +/// +/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file +/// and line they came from ([`crate::include`]). pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { + let expanded = match crate::include::expand(session, req.code, req.name) { + Ok(x) => x, + Err(e) => return Outcome { error: Some(e), ..Default::default() }, + }; + let Some(x) = expanded else { return run_expanded(session, req) }; + let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console }); + if let Some(e) = out.error.as_mut() { + x.locate(e); + } + out +} + +fn run_expanded(session: &mut Session, req: &ScriptRequest) -> Outcome { if let Err(message) = check_nesting(req.code) { return Outcome { error: Some(ScriptError { message, file: req.name.into(), ..Default::default() }), ..Default::default() }; } @@ -660,7 +758,7 @@ pub fn check_nesting(code: &str) -> Result<(), String> { pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome { let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() }; let s = std::mem::take(session); - ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name))); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name, host))); let guard = Restore { target: session }; let file = if req.name.is_empty() { "script" } else { req.name }; let (result, windows) = if req.console { @@ -681,10 +779,11 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome } else { match new_context() { Ok(mut ctx) => { - let r = eval_with_tasks(&mut ctx, req.code, file); + let r = eval_with_tasks(&mut ctx, req.code, file, host); let windows = crate::ui::snapshot(&mut ctx); - if windows.iter().any(|w| w.visible) { - // Palettes and panels outlive the run: keep their context for the handlers. + if windows.iter().any(|w| w.visible) || crate::tasks::has(host) { + // Palettes, panels and repeating tasks outlive the run: keep their context + // for the handlers and tasks. crate::ui::keep_inline(host, ctx); } (r, windows) @@ -694,6 +793,7 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome }; let output = settle(); drop(guard); + crate::tasks::set_epoch(host, session.script_ui.project_epoch); crate::ui::publish(session, host, req.name, windows); outcome(output, result) } diff --git a/crates/script/src/scriptfile.rs b/crates/script/src/scriptfile.rs new file mode 100644 index 00000000..2b6afcfb --- /dev/null +++ b/crates/script/src/scriptfile.rs @@ -0,0 +1,65 @@ +//! Script files a running script loads (`#include`, `$.evalFile`): where a name resolves, whether +//! the script may read it, and the limits that keep hostile, huge or cyclic files from running +//! away. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; + +use crate::runtime::{GATE, normalize, project_dir}; + +/// How deep loaded files may nest (a loaded file loading another…). +pub(crate) const MAX_DEPTH: usize = 32; +/// The largest single file a script may load. +pub(crate) const MAX_FILE_BYTES: usize = 16 << 20; + +/// The folder of the script file `name` runs from: `None` for the Script Console and for scripts +/// run by name or from code, which have no file. +pub(crate) fn script_dir(s: &Session, name: &str) -> Option { + let parent = Path::new(name).parent().filter(|p| !p.as_os_str().is_empty())?; + s.services.exists(name).then(|| normalize(&parent.to_string_lossy())) +} + +/// Resolve `name`: absolute paths as they are; relative ones against `dir` (the loading script's +/// folder), then each of `search`. The first existing file wins; when none exists, the first +/// candidate, so the read error names it. +pub(crate) fn resolve(s: &Session, name: &str, dir: Option<&Path>, search: &[PathBuf]) -> Result { + if Path::new(name).is_absolute() { + return Ok(normalize(name)); + } + let candidates: Vec = dir.into_iter().chain(search.iter().map(PathBuf::as_path)).map(|d| normalize(&d.join(name).to_string_lossy())).collect(); + candidates + .iter() + .find(|c| s.services.exists(&c.to_string_lossy())) + .or(candidates.first()) + .cloned() + .ok_or_else(|| format!("cannot find \"{name}\": the script has no folder to look in (use an absolute path or #includepath)")) +} + +/// The read gate for loaded script files: files under the running script's own folder (`root`) or +/// the project's folder; anywhere with the Allow Scripts to Write Files preference. +pub(crate) fn check_read(s: &Session, path: &Path, root: Option<&Path>) -> Result<(), String> { + if s.prefs.scripting.allow_scripts_write_files { + return Ok(()); + } + let project = project_dir(s).map(|d| normalize(&d)); + if root.into_iter().chain(project.as_deref()).any(|d| path.starts_with(d)) { + return Ok(()); + } + Err(format!("Unable to read {}: scripts can only load files from their own or the project's folder unless {GATE} is on", path.display())) +} + +/// Read a script file as text (a UTF-8 byte order mark dropped), refusing files over +/// [`MAX_FILE_BYTES`]. +pub(crate) fn read(s: &Session, path: &Path) -> Result { + let too_big = || format!("Unable to read {}: script files are limited to {} MiB", path.display(), MAX_FILE_BYTES >> 20); + if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) { + return Err(too_big()); + } + let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?; + if bytes.len() > MAX_FILE_BYTES { + return Err(too_big()); + } + let text = String::from_utf8_lossy(&bytes); + Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_string()) +} diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 8c1c09ed..af1e14b5 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -594,13 +594,21 @@ function __uiProto() { if (e.indexOf("on") !== 0) e = "on" + e.charAt(0).toUpperCase() + e.slice(1); this.__fire(e); }, - addEventListener: function (name, fn) { + // `capture`: the listener runs in the capture phase of mouse events (on the way down from + // the window to the target) instead of the bubble phase. + addEventListener: function (name, fn, capture) { var k = String(name).toLowerCase(); + if (capture) { + if (!this.__capture) this.__capture = Object.create(null); + (this.__capture[k] = this.__capture[k] || []).push(fn); + return; + } (this.__listeners[k] = this.__listeners[k] || []).push(fn); }, - removeEventListener: function (name, fn) { + removeEventListener: function (name, fn, capture) { var k = String(name).toLowerCase(); - if (this.__listeners[k]) this.__listeners[k] = this.__listeners[k].filter(function (f) { return f !== fn; }); + var m = capture ? this.__capture : this.__listeners; + if (m && m[k]) m[k] = m[k].filter(function (f) { return f !== fn; }); }, dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); }, __fire: function (name) { @@ -715,7 +723,91 @@ function __uiSize(d) { return [w > 0 ? w : 0, h > 0 ? h : 0]; } -function __uiJson(c) { +// ScriptUI mouse events: delivered to `addEventListener` listeners of the control under the +// pointer and its containers (capture, target, bubble), as the JavaScript Tools Guide documents. +var __uiMouseKinds = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "click"]; + +function __uiMouseListeners(c, type, capture) { + var m = capture ? c.__capture : c.__listeners; + if (!m || !Object.prototype.hasOwnProperty.call(m, type)) return []; + var l = m[type]; + return l instanceof Array ? l.slice() : []; +} + +// The mouse events that reach a listener at `c`: its own, and its containers' (`inherited`). +function __uiMouseWanted(c, inherited) { + var out = inherited.slice(); + for (var i = 0; i < __uiMouseKinds.length; i++) { + var t = __uiMouseKinds[i]; + if (out.indexOf(t) < 0 && (__uiMouseListeners(c, t, false).length || __uiMouseListeners(c, t, true).length)) out.push(t); + } + return out; +} + +// A ScriptUI MouseEvent. `v`: {x, y (relative to the target control), button (0 left, 1 middle, +// 2 right), detail (click count), screenX, screenY, altKey, ctrlKey, metaKey, shiftKey}. +function __uiMouseEvent(type, target, v) { + var num = function (x, d) { + x = Number(x); + return isFinite(x) ? x : d; + }; + var keys = { alt: !!v.altKey, control: !!v.ctrlKey, ctrl: !!v.ctrlKey, meta: !!v.metaKey, shift: !!v.shiftKey }; + return { + type: type, + target: target, + currentTarget: target, + eventPhase: "none", + bubbles: true, + cancelable: true, + captures: true, + view: target.window, + relatedTarget: undefined, + timeStamp: new Date(), + detail: num(v.detail, type === "mousemove" || type === "mouseover" || type === "mouseout" ? 0 : 1), + button: num(v.button, 0), + clientX: num(v.x, 0), + clientY: num(v.y, 0), + screenX: num(v.screenX, 0), + screenY: num(v.screenY, 0), + altKey: keys.alt, + ctrlKey: keys.ctrl, + metaKey: keys.meta, + shiftKey: keys.shift, + defaultPrevented: false, + __stopped: false, + stopPropagation: function () { this.__stopped = true; }, + preventDefault: function () { this.defaultPrevented = true; }, + getModifierState: function (k) { return !!keys[String(k).toLowerCase()]; }, + toString: function () { return "[object MouseEvent]"; } + }; +} + +// Deliver a mouse event to `target`: capture listeners from the window down, the target's own +// listeners, then bubble listeners back up. stopPropagation() ends it after the current control. +function __uiMouseFire(target, type, v) { + var path = []; + // A tree built by add() is shallow; the bound keeps a script's own `parent` cycle harmless. + for (var p = target; p && path.length < 256; p = p.parent) path.unshift(p); + var e = __uiMouseEvent(type, target, v); + var run = function (c, phase, capture) { + var l = __uiMouseListeners(c, type, capture); + e.currentTarget = c; + e.eventPhase = phase; + for (var i = 0; i < l.length; i++) if (typeof l[i] === "function") l[i].call(c, e); + }; + var n = path.length - 1; + for (var i = 0; i < n; i++) { + run(path[i], "capture", true); + if (e.__stopped) return e; + } + run(target, "target", true); + run(target, "target", false); + for (var j = n - 1; j >= 0 && !e.__stopped; j--) run(path[j], "bubble", false); + return e; +} + +function __uiJson(c, inherited) { + var mouse = __uiMouseWanted(c, inherited || []); var ps = __uiSize(c.preferredSize); if (c.type === "edittext" && c.characters > 0) ps = [c.characters * 7 + 12, ps ? ps[1] : 0]; var o = { @@ -730,6 +822,7 @@ function __uiJson(c) { children: [] }; if (ps) o.preferredSize = ps; + if (mouse.length) o.mouse = mouse; if (c.__ub && !c.__root) o.fixedBounds = c.__ub; if (c.__root && c.__ub) o.fixedBounds = [0, 0, c.__ub[2] - c.__ub[0], c.__ub[3] - c.__ub[1]]; if (__uiContainers[c.type]) { @@ -739,7 +832,7 @@ function __uiJson(c) { if (m) o.margins = m; if (typeof c.spacing === "number") o.spacing = c.spacing; var kids = c.children; - for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i])); + for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i], mouse)); } if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment); if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value; @@ -824,16 +917,26 @@ function __uiDispatch(winId, id, kind, valueJson) { for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false; c.value = true; }; + // A mouse click carries the pointer's details ({x, y, button, detail, …keys}). + var mouse = v !== null && typeof v === "object" && !(v instanceof Array) ? v : null; + if (kind !== "click" && __uiMouseKinds.indexOf(kind) >= 0) { + __uiMouseFire(c, kind, mouse || {}); + return; + } if (kind === "click") { if (c.type === "checkbox") c.value = !c.value; else if (c.type === "radiobutton") radio(); else if (c.type === "tab" && c.parent) { c.parent.selection = c; c.parent.__fire("onChange"); + if (mouse) __uiMouseFire(c, "click", mouse); return; } var handled = c.__handlers().indexOf("onClick") >= 0; - c.__fire("onClick"); + if (mouse) { + if (typeof c.onClick === "function") c.onClick.call(c); + __uiMouseFire(c, "click", mouse); + } else c.__fire("onClick"); // Dialog buttons without handlers: OK closes with 1, Cancel with 2. if (!handled && c.type === "button" && w.__kind === "dialog") { var n = String(c.name || c.properties.name || c.text).toLowerCase(); diff --git a/crates/script/src/tasks.rs b/crates/script/src/tasks.rs new file mode 100644 index 00000000..563f3bbc --- /dev/null +++ b/crates/script/src/tasks.rs @@ -0,0 +1,217 @@ +//! `app.scheduleTask(code, delay, repeat)` / `app.cancelTask(id)`. +//! +//! Tasks belong to the script context (host, see [`crate::ui`]) that scheduled them and run in +//! it. A script file's one-shot tasks run right after the script; everything else (repeating +//! tasks, and one-shot tasks scheduled later by handlers or other tasks) runs when it is due, as +//! frontends call [`run_tasks`] every frame (`ScriptUi::run_tasks`, `script.runTasks`). +//! +//! Lifetime: a repeating task runs every `delay` ms until `app.cancelTask(id)`, until it throws +//! (the error is reported once and the task cancelled), or until its owner goes away: a script +//! whose windows (ScriptUI panel, palette) were open stops when the last one closes; a script +//! without windows (and the Script Console) keeps its tasks until the project is closed or +//! replaced. Bounds: repeating tasks run at most every [`MIN_REPEAT_MS`], a script has at most +//! [`MAX_TASKS`] pending tasks, every run has the script engine's usual limits, and a due task +//! runs once per pass (no catch-up bursts after a long frame). +//! +//! Each thread keeps the tasks of the hosts it runs: a script-host thread its own, the caller's +//! thread those of inline hosts (the Script Console, web runs). + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use boa_engine::Context; +use effectcraft_engine::Session; +use serde_json::{Value as J, json}; +use web_time::{Duration, Instant}; + +use crate::runtime::{ScriptError, eval_in}; + +/// The shortest interval of a repeating task (milliseconds). +pub const MIN_REPEAT_MS: u64 = 50; +/// The most tasks one script may have pending. +pub const MAX_TASKS: usize = 64; +/// The longest delay (milliseconds), as in browsers' `setTimeout`. +const MAX_DELAY_MS: f64 = 2_147_483_647.0; + +struct Task { + id: u32, + code: String, + interval: Duration, + due: Instant, + repeat: bool, +} + +#[derive(Default)] +struct HostTasks { + next_id: u32, + list: Vec, + /// The script's name (inline hosts without windows run under it). + name: String, + /// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran (inline + /// hosts; the caller tracks script-host threads). + epoch: u64, +} + +thread_local! { + static TASKS: RefCell> = const { RefCell::new(BTreeMap::new()) }; +} + +/// Queue a task for `host`; its id. +pub(crate) fn schedule(host: u32, name: &str, epoch: u64, code: String, delay_ms: f64, repeat: bool, now: Instant) -> Result { + crate::runtime::check_nesting(&code).map_err(|e| format!("app.scheduleTask: {}", e.trim_start_matches("Error: ")))?; + let ms = if delay_ms.is_finite() { delay_ms.clamp(0.0, MAX_DELAY_MS) } else { 0.0 } as u64; + let interval = Duration::from_millis(if repeat { ms.max(MIN_REPEAT_MS) } else { ms }); + let due = now.checked_add(interval).ok_or("app.scheduleTask: the delay is too long")?; + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let h = t.entry(host).or_default(); + if h.list.len() >= MAX_TASKS { + return Err(format!("app.scheduleTask: a script can have at most {MAX_TASKS} scheduled tasks")); + } + h.next_id = h.next_id.wrapping_add(1).max(1); + let id = h.next_id; + h.list.push(Task { id, code, interval, due, repeat }); + h.name = name.to_string(); + h.epoch = epoch; + Ok(id) + }) +} + +/// `app.cancelTask(id)`. +pub(crate) fn cancel(host: u32, id: u32) { + TASKS.with(|t| { + if let Some(h) = t.borrow_mut().get_mut(&host) { + h.list.retain(|task| task.id != id); + } + }); +} + +/// Take the one-shot tasks of `host` (run right after a script file). +pub(crate) fn take_one_shots(host: u32) -> Vec { + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let Some(h) = t.get_mut(&host) else { return vec![] }; + let (once, keep) = std::mem::take(&mut h.list).into_iter().partition(|task| !task.repeat); + h.list = keep; + once.into_iter().map(|task: Task| task.code).collect() + }) +} + +/// Take the tasks due at `now`: one-shot tasks leave the queue, repeating ones are due again an +/// interval from now. (id, code, repeat). +fn take_due(host: u32, now: Instant) -> Vec<(u32, String, bool)> { + TASKS.with(|t| { + let mut t = t.borrow_mut(); + let Some(h) = t.get_mut(&host) else { return vec![] }; + let mut due = vec![]; + h.list.retain_mut(|task| { + if task.due > now { + return true; + } + due.push((task.id, task.code.clone(), task.repeat)); + match now.checked_add(task.interval) { + Some(next) if task.repeat => { + task.due = next; + true + } + _ => false, + } + }); + due + }) +} + +/// Whether `host` has pending tasks. +pub(crate) fn has(host: u32) -> bool { + TASKS.with(|t| t.borrow().get(&host).is_some_and(|h| !h.list.is_empty())) +} + +/// When `host`'s next task is due. +pub(crate) fn next_due(host: u32) -> Option { + TASKS.with(|t| t.borrow().get(&host).and_then(|h| h.list.iter().map(|task| task.due).min())) +} + +/// Forget `host`'s tasks. +pub(crate) fn drop_host(host: u32) { + TASKS.with(|t| t.borrow_mut().remove(&host)); +} + +/// The hosts on this thread with tasks: (host, script name, epoch). +pub(crate) fn hosts() -> Vec<(u32, String, u64)> { + TASKS.with(|t| t.borrow().iter().filter(|(_, h)| !h.list.is_empty()).map(|(id, h)| (*id, h.name.clone(), h.epoch)).collect()) +} + +pub(crate) fn set_epoch(host: u32, epoch: u64) { + TASKS.with(|t| { + if let Some(h) = t.borrow_mut().get_mut(&host) { + h.epoch = epoch; + } + }); +} + +/// Run `host`'s due tasks in `ctx` (the session is active): how many ran, and the first error. +/// A task that throws is cancelled (a repeating one would only repeat the error). +pub(crate) fn run_due(ctx: &mut Context, host: u32, now: Instant) -> (usize, Option) { + let (mut ran, mut first) = (0, None); + for (id, code, repeat) in take_due(host, now) { + ran += 1; + if let Err(mut e) = eval_in(ctx, &code, "scheduled task") { + if repeat { + cancel(host, id); + e.message = format!("{} (repeating task {id} cancelled)", e.message); + } + let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default(); + let _ = crate::runtime::with_active(|a| { + a.output.push(format!("Error in scheduled task {id}{line}: {}", e.message)); + Ok(()) + }); + first.get_or_insert(e); + } + } + (ran, first) +} + +/// What a pass over the scheduled tasks did. +#[derive(Default)] +pub(crate) struct Report { + ran: usize, + output: Vec, + errors: Vec, + next: Option, +} + +impl Report { + pub(crate) fn add(&mut self, out: crate::Outcome) { + self.ran += out.result.as_u64().unwrap_or(0) as usize; + self.output.extend(out.output); + self.errors.extend(out.error); + } + + pub(crate) fn due(&mut self, at: Option) { + if let Some(at) = at { + self.next = Some(self.next.map_or(at, |n| n.min(at))); + } + } +} + +/// The [`effectcraft_engine::scriptui::ScriptTaskRunner`] this crate provides: run the tasks +/// that are due now. +pub fn run_tasks(session: &mut Session) -> J { + run_tasks_at(session, Instant::now()) +} + +/// [`run_tasks`] at `now`. +pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J { + let mut report = Report::default(); + // Not from inside a script (`script.runTasks` through `app.executeCommand`). + let running = crate::runtime::ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)); + if !running { + #[cfg(not(target_arch = "wasm32"))] + crate::ui::tick_threaded(session, now, &mut report); + crate::ui::tick_inline(session, now, &mut report); + crate::ui::prune_task_hosts(session); + } + let errors: Vec = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect(); + let next = report.next.map(|n| n.saturating_duration_since(now).as_millis() as u64); + json!({"ran": report.ran, "output": report.output, "errors": errors, "next": next}) +} diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index b50a7bfb..303bd820 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -730,3 +730,283 @@ fn import_options_sequence_picks_one_still_or_the_run() { assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn binary_file_io_preserves_all_byte_values() { + let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("sig.bin"); + let dst = dir.join("written.bin"); + let copied = dir.join("copied.bin"); + let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a]; + std::fs::write(&src, original).unwrap(); + let mut s = session(); + s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string()); + s.prefs.scripting.allow_scripts_write_files = true; + let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap(); + + let read = ok( + &mut s, + &format!( + "var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]", + q(&src) + ), + ); + assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff])); + + ok( + &mut s, + &format!( + "var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();", + q(&dst) + ), + ); + assert_eq!(std::fs::read(&dst).unwrap(), original); + + let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied))); + assert_eq!(copy.result, json!(true)); + assert_eq!(std::fs::read(&copied).unwrap(), original); + + ok(&mut s, &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();", q(&dst))); + assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat()); + + let invalid = run_code( + &mut s, + &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)), + "invalid-binary.jsx", + ); + assert!(invalid.error.is_some(), "code points above 255 must not be truncated"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_undo_uses_stable_after_effects_command_id() { + let mut s = session(); + let lookup = ok(&mut s, "app.findMenuCommandId('Undo')"); + assert_eq!(lookup.result, json!(16)); + // ID 16 used to index an unrelated command as the registry grew. It must + // undo the preceding layer creation rather than dispatching by position. + let r = ok(&mut s, "var c = app.project.items.addComp('Undo ID', 100, 100, 1, 1, 24); c.layers.addNull(); app.executeCommand(16); c.numLayers"); + assert_eq!(r.result, json!(0)); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0); + // Lookup of other command names still uses the existing registry. + assert!(ok(&mut s, "app.findMenuCommandId('New Project')").result.as_u64().unwrap_or(0) > 0); +} + +#[test] +fn long_script_undo_group_counts_as_one_level() { + let mut s = session(); + s.prefs.general.undo_levels = 3; + ok(&mut s, "var c = app.project.items.addComp('Long Undo', 100, 100, 1, 1, 24); c.layers.addNull();"); + assert_eq!(s.history.undo.len(), 2); + // More edits than the history cap must still be one undoable group. + ok(&mut s, "var c = app.project.item(1); app.beginUndoGroup('Forty layers'); for (var i = 0; i < 40; i++) c.layers.addNull(); app.endUndoGroup();"); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 41); + assert_eq!(s.history.undo.len(), 3); + assert_eq!(s.history.undo[2].0, "Forty layers"); + s.undo(); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 1); + // Entries preceding the group remain available instead of being trimmed by + // the forty intermediate operations. + s.undo(); + assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0); +} + +#[test] +fn script_remove_keys_batches_a_range_as_one_edit() { + let mut s = session(); + let o = ok( + &mut s, + "var c = app.project.items.addComp('Batch Keys', 100, 100, 1, 10, 25); var p = c.layers.addNull().transform.rotation; for (var i = 0; i < 5; i++) p.setValueAtTime(i, i * 10); p.removeKeys(2, 4); [p.numKeys, p.keyTime(1), p.keyTime(2)]", + ); + assert_eq!(o.result, json!([2, 0, 4])); + // The entire deletion is one undo step; keys outside the range survive. + s.undo(); + let restored = ok(&mut s, "app.project.item(1).layer(1).transform.rotation.numKeys"); + assert_eq!(restored.result, json!(5)); + let invalid = run_code(&mut s, "app.project.item(1).layer(1).transform.rotation.removeKeys(0, 2)", "remove-keys-range.jsx"); + assert!(invalid.error.is_some()); +} + +#[test] +fn script_layer_comment_and_unnamed_marker_params_round_trip() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Notes", 100, 100, 1, 5, 25); + var l = c.layers.addSolid([1, 0, 0], "Solid", 10, 10, 1); + l.comment = "Saved layer note"; + var markers = l.property("ADBE Marker"); + var plain = new MarkerValue("Plain"); + plain.setParameters({ lml: "shot", index: "1" }); + markers.setValueAtTime(1, plain); + var named = new MarkerValue("Named"); + named.cuePointName = "cue"; + named.setParameters({ lml: "shot", index: "2" }); + markers.setValueAtTime(2, named); + [l.comment, markers.keyValue(1).getParameters().index, + markers.keyValue(1).cuePointName, markers.keyValue(2).getParameters().index] + "#, + ); + assert_eq!(o.result, json!(["Saved layer note", "1", "", "2"])); +} + +#[test] +fn script_av_layer_transform_shortcuts_return_live_properties() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Shortcuts", 100, 100, 1, 5, 25); + var l = c.layers.addSolid([1, 0, 0], "S", 10, 10, 1); + l.position.setValue([30, 40]); + l.rotation.setValueAtTime(1, 45); + [l.position.value[0], l.transform.position.value[1], l.rotation.numKeys, + l.opacity === undefined, l.scale === undefined, l.anchorPoint === undefined] + "#, + ); + assert_eq!(o.result, json!([30, 40, 1, false, false, false])); + s.undo(); + let restored = ok(&mut s, "app.project.item(1).layer(1).rotation.numKeys"); + assert_eq!(restored.result, json!(0)); +} + +#[test] +fn extendscript_substr_keeps_start_and_length_semantics() { + let mut s = session(); + let o = ok( + &mut s, + r#"[ + "abcdef".substr(0, 2), + "abcdef".substr(2, 3), + "abcdef".substr(-2), + "abcdef".substr(-20, 2), + "abcdef".substr(2, 0), + "abcdef".substr(2, -3), + "abcdef".substr(2.9, 2.9), + "abcdef".substr(3), + "abcdef".substr(30, 2), + "abcdef".substr(NaN, 2), + "abcdef".substr(2, NaN) + ]"#, + ); + assert_eq!(o.result, json!(["ab", "cde", "ef", "ab", "", "", "cd", "def", "", "ab", ""])); +} + +#[test] +fn import_options_preserve_layered_psd_compositions() { + use effectcraft_psd::Rect; + use effectcraft_psd::write::{WDoc, WLayer, write}; + + let dir = std::env::temp_dir().join(format!("ec-script-import-as-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("layers.psd"); + let mut doc = WDoc::new(12, 12); + doc.layers.push(WLayer::solid("Background", Rect::new(0, 0, 12, 12), [1.0, 1.0, 1.0, 1.0])); + doc.layers.push(WLayer::solid("Accent", Rect::new(2, 2, 4, 4), [0.0, 0.4, 1.0, 1.0])); + std::fs::write(&source, write(&doc)).unwrap(); + let file = serde_json::to_string(&source.to_string_lossy()).unwrap(); + let mut s = session(); + let o = ok( + &mut s, + &format!( + "var io = new ImportOptions(new File({file})); io.importAs = ImportAsType.COMPOSITION_CROPPED_LAYERS; \ + [io.canImportAs(io.importAs), app.project.importFile(io).typeName]" + ), + ); + assert_eq!(o.result, json!([true, "Composition"])); + let imported = s.project.comps().next().unwrap().1; + assert_eq!(imported.layers.len(), 2); + let bad = ok(&mut s, &format!("new ImportOptions(new File({file})).canImportAs(ImportAsType.PROJECT)")); + assert_eq!(bad.result, json!(false)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_item_and_layer_identity_survives_repeated_access() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Identity", 120, 80, 1, 3, 25); + var parent = c.layers.addNull(), child = c.layers.addNull(); + child.parent = parent; + [ + parent.containingComp === c, + c.layer(1) === c.layer(1), + child.parent === parent, + app.project.item(1) === app.project.item(1), + app.project.item(1) === c, + app.project.itemByID(c.id) === c + ] + "#, + ); + assert_eq!(o.result, json!([true, true, true, true, true, true])); + // Reusing item and layer IDs in a new project must not alias old wrappers. + let o = ok( + &mut s, + r#" + var old = app.project.item(1); + app.newProject(); + var next = app.project.items.addComp("New", 120, 80, 1, 3, 25); + [old !== next, app.project.item(1) === next] + "#, + ); + assert_eq!(o.result, json!([true, true])); +} + +#[test] +fn script_text_path_options_expose_after_effects_match_names() { + let mut s = session(); + let r = ok( + &mut s, + r#" + var c = app.project.items.addComp("Text Path", 160, 90, 1, 2, 24); + var t = c.layers.addText("On a path"); + var options = t.property("ADBE Text Properties").property("ADBE Text Path Options"); + var names = []; + for (var i = 1; i <= options.numProperties; i++) + names.push(options.property(i).matchName); + [names, options.property("ADBE Text Path").matchName, + options.property("ADBE Text Perpendicular To Path").matchName] + "#, + ); + assert_eq!( + r.result, + json!([ + [ + "ADBE Text Path", + "ADBE Text Reverse Path", + "ADBE Text Perpendicular To Path", + "ADBE Text Force Align Path", + "ADBE Text First Margin", + "ADBE Text Last Margin" + ], + "ADBE Text Path", + "ADBE Text Perpendicular To Path" + ]) + ); +} + +/// A scripted text animator has only the selectors the script adds, and the Range Selector and +/// its Start/End/Offset resolve by After Effects match name (#837). +#[test] +fn scripted_text_range_selector_resolves_and_is_the_only_selector() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Sel", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + a.property("ADBE Text Animator Properties").addProperty("ADBE Text Opacity").setValue(0); + var sels = a.property("ADBE Text Selectors"); + var sel = sels.addProperty("ADBE Text Selector"); + sel.property("ADBE Text Percent Start").setValue(50); + var end = sel.property("ADBE Text Percent End").value; + [sels.numProperties, sel.matchName, sel.property("start").value, end, sel.property("ADBE Text Percent Offset").matchName] + "#, + ); + assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); +} diff --git a/crates/script/src/tests_callable.rs b/crates/script/src/tests_callable.rs new file mode 100644 index 00000000..13d6f787 --- /dev/null +++ b/crates/script/src/tests_callable.rs @@ -0,0 +1,96 @@ +//! Layers, property groups and properties are callable like After Effects (#758): +//! `layer("ADBE Effect Parade")`, `fxGroup(1)` and `fx("Slider")` are `.property(...)`. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value as KV; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() }; + crate::install(&mut s); + s +} + +fn ok(s: &mut Session, code: &str) -> Outcome { + let o = run_code(s, code, "test.jsx"); + assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output); + o +} + +#[test] +fn layers_groups_and_properties_are_callable() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("M", 200, 200, 1, 2, 30); + var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1); + l("ADBE Effect Parade").addProperty("ADBE Slider Control"); + var fxGroup = l("Effects"); + var fx = fxGroup(1); + fx("Slider").setValue(42); + l("ADBE Mask Parade").addProperty("ADBE Mask Atom"); + var pos = l("ADBE Transform Group")("ADBE Position"); + [ + l("ADBE Effect Parade").matchName, fxGroup.numProperties, fx.matchName, fx.name, + l("Effects")("Slider Control")("Slider").value, fxGroup(1)(1).value, + l("Masks")(1).matchName, l("Masks")(1)("ADBE Mask Shape").name, + pos.matchName, pos.name, pos.propertyIndex, pos.parentProperty("Opacity").value, + pos.parentProperty.parentProperty(1).matchName === l.property(1).matchName, + l("Nope") === null, fxGroup(9) === null, pos("anything") === null, + l instanceof AVLayer, fxGroup instanceof PropertyGroup, pos instanceof Property, l.name, + l.transform.position.matchName, l.Effects(1).matchName + ] + "#, + ); + assert_eq!( + o.result, + json!([ + "ADBE Effect Parade", + 1, + "ADBE Slider Control", + "Slider Control", + 42, + 42, + "ADBE Mask Atom", + "Mask Path", + "ADBE Position", + "Position", + 2, + 100, + true, + true, + true, + true, + true, + true, + true, + "S", + "ADBE Position", + "ADBE Slider Control" + ]) + ); + let c = s.project.items.values().find_map(|i| i.as_comp()).expect("comp"); + let l = c.layers.first().expect("layer"); + assert_eq!(l.props.prop("effects/#1/slider").map(|p| p.value.clone()), Some(KV::Scalar(42.0))); +} + +#[test] +fn calling_without_a_name_is_a_script_error() { + let mut s = session(); + for code in [ + "var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l();", + "var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l('Transform')(null);", + "var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); new l('Transform');", + ] { + let o = run_code(&mut s, code, "test.jsx"); + assert!(o.error.is_some(), "expected an error for {code}"); + } + // A called layer still reports itself as a layer, and its JSON result is unchanged. + let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l"); + assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" })); +} diff --git a/crates/script/src/tests_evalfile.rs b/crates/script/src/tests_evalfile.rs new file mode 100644 index 00000000..c97a57b6 --- /dev/null +++ b/crates/script/src/tests_evalfile.rs @@ -0,0 +1,99 @@ +//! #760: `$.evalFile` and the `BridgeTalk` host identification. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s +} + +fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-evalfile-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does). +fn run_file(s: &mut Session, main: &Path) -> Outcome { + let code = std::fs::read_to_string(main).unwrap(); + run_code(s, &code, &main.to_string_lossy()) +} + +#[test] +fn eval_file_evaluates_relative_to_the_calling_script() { + let dir = tmp("ok"); + // lib/a.jsx loads b.jsx next to itself, not next to main.jsx. + write(&dir.join("lib/a.jsx"), "var fromA = $.evalFile('b.jsx') + 1;\nfunction twice(x) { return 2 * x; }\nfromA"); + write(&dir.join("lib/b.jsx"), "var fromB = 20;\nfromB"); + write( + &dir.join("main.jsx"), + "var r = $.evalFile(new File($.fileName).path + '/lib/a.jsx');\nvar s = $.evalFile('lib/b.jsx');\n[r, s, twice(fromB), typeof fromA]", + ); + let mut s = session(); + let o = run_file(&mut s, &dir.join("main.jsx")); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!([21, 20, 40, "number"])); + // Errors in the evaluated file reach the caller, which can catch them. + write(&dir.join("bad.jsx"), "throw new Error('bad file');"); + write(&dir.join("main.jsx"), "var m; try { $.evalFile('bad.jsx'); } catch (e) { m = e.message; } m"); + assert_eq!(run_file(&mut s, &dir.join("main.jsx")).result, json!("bad file")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn eval_file_is_bounded_and_gated() { + let dir = tmp("hostile"); + // A file that evaluates itself forever stops at the depth limit. + write(&dir.join("loop.jsx"), "$.evalFile('loop.jsx');"); + let mut s = session(); + let e = run_file(&mut s, &dir.join("loop.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Deeply bracketed files are refused before they reach the parser. + write(&dir.join("deep.jsx"), &"[".repeat(100_000)); + write(&dir.join("main.jsx"), "$.evalFile('deep.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Missing files are a clear error. + write(&dir.join("main.jsx"), "$.evalFile('missing.jsx')"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("missing.jsx"), "{e:?}"); + // Files outside the script's (and the project's) folder need the file preference. + write(&dir.join("outside.jsx"), "7"); + write(&dir.join("app/main.jsx"), "$.evalFile('../outside.jsx')"); + let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked"); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_file(&mut s, &dir.join("app/main.jsx")); + assert_eq!(o.result, json!(7), "{:?}", o.error); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bridgetalk_identifies_effectcraft() { + let mut s = session(); + let o = run_code( + &mut s, + "[typeof BridgeTalk, BridgeTalk.appName, BridgeTalk.appVersion, BridgeTalk.appSpecifier, BridgeTalk.isRunning('aftereffects'), \ + BridgeTalk.getSpecifier('effectcraft'), BridgeTalk.getStatus('photoshop'), new BridgeTalk().sender]", + "bt.jsx", + ); + assert!(o.error.is_none(), "{:?}", o.error); + let v = env!("CARGO_PKG_VERSION"); + let spec = format!("effectcraft-{v}"); + assert_eq!(o.result, json!(["function", "effectcraft", v, spec, false, spec, "ISNOTINSTALLED", spec])); + // Messages to other applications are unsupported, with a clear error. + let e = run_code(&mut s, "var bt = new BridgeTalk(); bt.target = 'photoshop'; bt.body = '1'; bt.send();", "bt.jsx").error.expect("unsupported"); + assert!(e.message.contains("not supported"), "{e:?}"); +} diff --git a/crates/script/src/tests_include.rs b/crates/script/src/tests_include.rs new file mode 100644 index 00000000..3c7d1024 --- /dev/null +++ b/crates/script/src/tests_include.rs @@ -0,0 +1,94 @@ +//! #759: ExtendScript's `#include` / `//@include` / `#includepath` directives. + +use std::path::{Path, PathBuf}; + +use effectcraft_engine::Session; +use serde_json::json; + +use crate::{Outcome, run_code}; + +fn session() -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s +} + +fn tmp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-include-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does). +fn run_file(s: &mut Session, main: &Path) -> Outcome { + let code = std::fs::read_to_string(main).unwrap(); + run_code(s, &code, &main.to_string_lossy()) +} + +#[test] +fn includes_are_inlined_relative_to_the_script() { + let dir = tmp("ok"); + write(&dir.join("lib/math.jsx"), "#include \"util.jsxinc\"\nfunction twice(x) { return 2 * x; }"); + write(&dir.join("lib/util.jsxinc"), "// includes its includer back: inlined once only\n#include \"math.jsx\"\nvar unit = 'px';"); + write(&dir.join("vendor/extra.jsx"), "var extra = 40;"); + write( + &dir.join("main.jsx"), + "#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('/').pop()]", + ); + let mut s = session(); + let o = run_file(&mut s, &dir.join("main.jsx")); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!([42, "px", "main.jsx"])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn errors_point_at_the_file_and_line_they_came_from() { + let dir = tmp("err"); + write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;\nnotDefined();"); + write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;"); + let mut s = session(); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.file.ends_with("lib.jsx") && e.line == Some(3), "{e:?}"); + // After the include, lines in the script itself keep their numbers. + write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;"); + write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;\nalsoNotDefined();"); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.file.ends_with("main.jsx") && e.line == Some(4), "{e:?}"); + // A missing file is a clear error on the directive's line. + write(&dir.join("main.jsx"), "var x = 1;\n#include \"missing.jsx\""); + let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error"); + assert!(e.message.contains("missing.jsx") && e.line == Some(2), "{e:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn hostile_includes_are_bounded_and_gated() { + let dir = tmp("hostile"); + // A chain deeper than the limit is an error, not a stack overflow. + for i in 0..100 { + write(&dir.join(format!("chain/{i}.jsx")), &format!("#include \"{}.jsx\"", i + 1)); + } + write(&dir.join("chain/100.jsx"), "1"); + let mut s = session(); + let e = run_file(&mut s, &dir.join("chain/0.jsx")).error.expect("an error"); + assert!(e.message.contains("nested too deeply"), "{e:?}"); + // Files outside the script's (and the project's) folder need the file preference. + write(&dir.join("outside.jsx"), "var secret = 7;"); + write(&dir.join("app/main.jsx"), "#include \"../outside.jsx\"\nsecret"); + let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked"); + assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}"); + // Code run without a file can't include relative to anything but the project's folder. + let e = run_code(&mut s, "#include \"outside.jsx\"", "console").error.expect("no folder"); + assert!(e.message.contains("outside.jsx"), "{e:?}"); + s.prefs.scripting.allow_scripts_write_files = true; + let o = run_file(&mut s, &dir.join("app/main.jsx")); + assert_eq!(o.result, json!(7), "{:?}", o.error); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/script/src/tests_tasks.rs b/crates/script/src/tests_tasks.rs new file mode 100644 index 00000000..4ee79984 --- /dev/null +++ b/crates/script/src/tests_tasks.rs @@ -0,0 +1,180 @@ +//! `app.scheduleTask` / `app.cancelTask` (#773): repeating tasks run whenever they are due (frontends +//! tick [`crate::run_tasks`] every frame) until they are cancelled, throw, or their owner goes +//! away; one-shot tasks scheduled later run when due too. + +use std::sync::Arc; + +use effectcraft_engine::{ScriptRequest, Session}; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::run_code; +use crate::tasks::run_tasks_at; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +fn ok(s: &mut Session, code: &str) { + let o = run_code(s, code, "tasks.jsx"); + assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output); +} + +fn console(s: &mut Session, code: &str) -> crate::Outcome { + let o = crate::run(s, &ScriptRequest { code, name: "console", console: true }); + assert!(o.error.is_none(), "console failed: {:?}", o.error); + o +} + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn output(r: &Value) -> Vec { + r["output"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() +} + +fn comps(s: &Session) -> usize { + s.project.items.values().filter(|i| i.as_comp().is_some()).count() +} + +#[test] +fn a_repeating_task_runs_every_interval_until_cancelled() { + let mut s = session(); + ok( + &mut s, + "var n = 0;\nvar id = app.scheduleTask(\"n++; writeLn('tick ' + n); app.project.items.addComp('C' + n, 64, 64, 1, 1, 25); if (n == 3) app.cancelTask(id);\", 1000, true);", + ); + let t0 = Instant::now(); + // Not due yet: nothing runs, and the frontend learns when to come back. + let r = run_tasks_at(&mut s, t0); + assert_eq!(r["ran"], json!(0)); + assert!(r["next"].as_u64().is_some_and(|n| n <= 1000), "{r}"); + let r = run_tasks_at(&mut s, t0 + ms(1500)); + assert_eq!((r["ran"].clone(), output(&r)), (json!(1), vec!["tick 1".to_string()]), "{r}"); + assert_eq!(comps(&s), 1, "the task edits the project through engine commands"); + // Due again an interval after it ran, once per pass (no catch-up burst). + assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(0)); + assert_eq!(run_tasks_at(&mut s, t0 + ms(2600))["ran"], json!(1)); + let r = run_tasks_at(&mut s, t0 + ms(9000)); + assert_eq!(output(&r), vec!["tick 3"]); + // Cancelled: the script (no windows, no tasks) has ended. + let r = run_tasks_at(&mut s, t0 + ms(20_000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); + assert_eq!(comps(&s), 3); + assert!(s.history.undo.len() >= 3, "every run is undoable"); +} + +#[test] +fn a_panels_task_refreshes_it_until_the_panel_closes() { + let mut s = session(); + ok( + &mut s, + r#" +var w = new Window("palette", "Clock"); +var label = w.add("statictext", undefined, "0"); +var n = 0; +app.scheduleTask("n++; label.text = String(n);", 200, true); +w.show(); +"#, + ); + let win = s.script_ui.windows[0].id; + let text = |s: &Session| s.script_ui.window(win).map(|w| w.root.children[0].text.clone()); + let t0 = Instant::now(); + run_tasks_at(&mut s, t0 + ms(500)); + run_tasks_at(&mut s, t0 + ms(1000)); + assert_eq!(text(&s).as_deref(), Some("2"), "the window shows what the task set"); + // A project change doesn't stop a script whose window is open. + s.execute("file.closeProject", json!({})).unwrap(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(1)); + assert_eq!(text(&s).as_deref(), Some("3")); + // Closing the window ends the script and its tasks. + s.execute("scriptui.close", json!({"window": win})).unwrap(); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn a_throwing_task_is_reported_once_and_cancelled() { + let mut s = session(); + ok(&mut s, "app.scheduleTask('undefinedThing.go()', 100, true); app.scheduleTask('writeLn(\"fine\")', 100, true);"); + let t0 = Instant::now(); + let r = run_tasks_at(&mut s, t0 + ms(500)); + assert_eq!(r["ran"], json!(2)); + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + assert_eq!(errors.len(), 1, "{r}"); + assert!(errors[0]["message"].as_str().is_some_and(|m| m.contains("undefinedThing") && m.contains("cancelled")), "{r}"); + // The other task goes on; the failing one doesn't run again. + let r = run_tasks_at(&mut s, t0 + ms(1000)); + assert_eq!((r["ran"].clone(), output(&r), r["errors"].clone()), (json!(1), vec!["fine".to_string()], json!([])), "{r}"); +} + +#[test] +fn tasks_are_bounded() { + let mut s = session(); + // Repeating tasks run at most every MIN_REPEAT_MS. + ok(&mut s, "var hits = 0; app.scheduleTask('hits++', 0, true);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(20))["ran"], json!(1)); + assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(30))["ran"], json!(0)); + // At most MAX_TASKS pending tasks per script. + let o = run_code(&mut s, "for (var i = 0; i < 1000; i++) app.scheduleTask('1', 1000, true);", "many.jsx"); + assert!(o.error.as_ref().is_some_and(|e| e.message.contains(&format!("at most {}", crate::MAX_TASKS))), "{:?}", o.error); + // Task code nested too deeply for the parser is refused up front. + let deep = format!("app.scheduleTask('{}', 10, false);", "(".repeat(10_000)); + let o = run_code(&mut s, &deep, "deep.jsx"); + assert!(o.error.as_ref().is_some_and(|e| e.message.contains("nested too deeply")), "{:?}", o.error); + // Odd delays and ids are harmless. + ok( + &mut s, + "app.cancelTask(-1); app.cancelTask(NaN); app.cancelTask(1e20); app.scheduleTask('1', NaN, false); app.scheduleTask('1', -5, false); app.scheduleTask('1', 1e300, true);", + ); +} + +#[test] +fn closing_the_project_stops_tasks_of_scripts_without_windows() { + let mut s = session(); + ok(&mut s, "app.scheduleTask('writeLn(1)', 100, true);"); + console(&mut s, "var cid = app.scheduleTask('writeLn(2)', 100, true);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(500))["ran"], json!(2)); + s.execute("file.closeProject", json!({})).unwrap(); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn console_tasks_run_in_the_console_and_cancel_there() { + let mut s = session(); + console(&mut s, "var hits = 0; var tid = app.scheduleTask('hits++', 100, true); app.scheduleTask('writeLn(\"once \" + hits)', 300, false);"); + let t0 = Instant::now(); + assert_eq!(run_tasks_at(&mut s, t0 + ms(200))["ran"], json!(1)); + let r = run_tasks_at(&mut s, t0 + ms(400)); + assert_eq!((r["ran"].clone(), output(&r)), (json!(2), vec!["once 2".to_string()]), "{r}"); + assert_eq!(console(&mut s, "app.cancelTask(tid); hits").result, json!(2)); + let r = run_tasks_at(&mut s, t0 + ms(5000)); + assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}"); +} + +#[test] +fn handlers_schedule_one_shot_tasks_that_run_when_due() { + let mut s = session(); + ok( + &mut s, + r#" +var w = new Window("palette", "Later"); +var b = w.add("button", undefined, "Go", { name: "go" }); +b.onClick = function () { app.scheduleTask("writeLn('later')", 100, false); }; +w.show(); +"#, + ); + s.execute("scriptui.click", json!({"widget": "go"})).unwrap(); + let r = run_tasks_at(&mut s, Instant::now() + ms(500)); + assert_eq!(output(&r), vec!["later"], "{r}"); + // The engine command runs the same pass (headless hosts, agents). + assert_eq!(s.execute("script.runTasks", json!({})).unwrap()["ran"], json!(0)); + s.execute("scriptui.close", json!({})).unwrap(); +} diff --git a/crates/script/src/tests_ui_mouse.rs b/crates/script/src/tests_ui_mouse.rs new file mode 100644 index 00000000..ddacac01 --- /dev/null +++ b/crates/script/src/tests_ui_mouse.rs @@ -0,0 +1,167 @@ +//! ScriptUI mouse events (#766): `addEventListener("mousedown" | "mouseup" | "mousemove" | +//! "mouseover" | "mouseout" | "click", handler)` listeners receive a MouseEvent through +//! `scriptui.mouse`, with capture / target / bubble phases as the JavaScript Tools Guide +//! documents. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::run_code; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +const CANVAS: &str = r#" +var w = new Window("palette", "Canvas"); +var g = w.add("group", undefined, undefined, { name: "row" }); +var pad = g.add("group", undefined, undefined, { name: "pad" }); +pad.preferredSize = [120, 80]; +var box = w.add("checkbox", undefined, "Snap", { name: "snap" }); +var plain = w.add("statictext", undefined, "no listeners", { name: "plain" }); +function rec(tag) { + return function (e) { + writeLn(tag + ":" + e.type + "@" + e.eventPhase + " " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail + + (e.shiftKey ? " shift" : "") + (e.altKey ? " alt" : "") + (e.getModifierState("Control") ? " ctrl" : "") + + (e.currentTarget === this ? "" : " WRONG-THIS") + " " + e.target.properties.name); + }; +} +pad.addEventListener("mousedown", rec("pad")); +pad.addEventListener("mousemove", rec("pad")); +pad.addEventListener("mouseover", rec("pad")); +pad.addEventListener("mouseout", rec("pad")); +pad.addEventListener("mouseup", rec("pad")); +g.addEventListener("mousedown", rec("row")); +w.addEventListener("mousedown", rec("win"), true); +box.onClick = function () { writeLn("onClick " + box.value); }; +box.addEventListener("click", rec("box")); +w.show(); +"#; + +fn lines(r: &Value) -> Vec { + assert_eq!(r["ok"], true, "{r}"); + r["output"].as_str().unwrap_or_default().lines().map(str::to_string).collect() +} + +#[test] +fn mouse_events_reach_listeners_with_pointer_details() { + let mut s = session(); + let o = run_code(&mut s, CANVAS, "canvas.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + let w = s.script_ui.windows[0].clone(); + // The published tree says which controls want which mouse events (containers' listeners + // reach their children: events bubble). + let pad = w.root.lookup(&json!("pad")).unwrap(); + assert_eq!(pad.mouse, ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout"]); + assert_eq!(w.root.lookup(&json!("snap")).unwrap().mouse, ["mousedown", "click"], "the window's capture listener + its own"); + assert_eq!(w.root.lookup(&json!("plain")).unwrap().mouse, ["mousedown"]); + // A press: capture on the window, the target, then bubbling up through the group. + let r = s + .execute( + "scriptui.mouse", + json!({"widget": "pad", "type": "mousedown", "x": 12.5, "y": 30, "button": 2, "detail": 2, "shiftKey": true, "ctrlKey": true}), + ) + .unwrap(); + assert_eq!( + lines(&r), + [ + "win:mousedown@capture 12.5,30 b2 d2 shift ctrl pad", + "pad:mousedown@target 12.5,30 b2 d2 shift ctrl pad", + "row:mousedown@bubble 12.5,30 b2 d2 shift ctrl pad", + ] + ); + // Hover and moves carry no click count; x / y default to the control's centre. + let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "mouseover"})).unwrap(); + assert_eq!(lines(&r), ["pad:mouseover@target 60,40 b0 d0 pad"]); + let r = s.execute("scriptui.mouse", json!({"widget": "pad", "type": "MouseMove", "x": 3, "y": 4, "altKey": true})).unwrap(); + assert_eq!(lines(&r), ["pad:mousemove@target 3,4 b0 d0 alt pad"]); + // An event nobody listens for on a control runs nothing (but still answers). + let r = s.execute("scriptui.mouse", json!({"widget": "plain", "type": "mouseup"})).unwrap(); + assert!(lines(&r).is_empty()); + // A mouse click does what a click does (the checkbox toggles once, onClick runs) and its + // listeners get the pointer. + let r = s.execute("scriptui.mouse", json!({"widget": "snap", "type": "click", "x": 5, "y": 6})).unwrap(); + assert_eq!(lines(&r), ["onClick true", "box:click@target 5,6 b0 d1 snap"]); + assert!(s.script_ui.windows[0].root.lookup(&json!("snap")).unwrap().checked); + // Agents' plain click still fires the click listeners. + let r = s.execute("scriptui.click", json!({"widget": "snap"})).unwrap(); + assert_eq!(r["output"].as_str().unwrap_or_default().lines().next(), Some("onClick false")); + s.execute("scriptui.close", json!({})).unwrap(); +} + +#[test] +fn stop_propagation_capture_and_removal() { + let mut s = session(); + let code = r#" + var w = new Window("palette", "Stops"); + var g = w.add("group", undefined, undefined, { name: "outer" }); + var b = g.add("button", undefined, "Go", { name: "go" }); + var stopHere = function (e) { writeLn("capture " + e.currentTarget.properties.name); e.stopPropagation(); }; + g.addEventListener("mouseup", stopHere, true); + b.addEventListener("mouseup", function () { writeLn("never"); }); + var bubble = function (e) { writeLn("bubble " + e.eventPhase); }; + g.addEventListener("mousedown", bubble); + b.addEventListener("mousedown", function (e) { writeLn("target"); e.stopPropagation(); e.preventDefault(); writeLn("prevented " + e.defaultPrevented); }); + b.addEventListener("mousemove", function () { writeLn("move"); }); + b.removeEventListener("mousemove", b.__listeners.mousemove[0]); + w.show(); + "#; + let o = run_code(&mut s, code, "stops.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + // A capture listener that stops: the target never hears it. + let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mouseup"})).unwrap(); + assert_eq!(lines(&r), ["capture outer"]); + // Stopped at the target: no bubbling to the group. + let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousedown"})).unwrap(); + assert_eq!(lines(&r), ["target", "prevented true"]); + // The group itself as the target. + let r = s.execute("scriptui.mouse", json!({"widget": "outer", "type": "mousedown"})).unwrap(); + assert_eq!(lines(&r), ["bubble target"]); + // Removed listeners don't run, and no longer ask for the event. + let r = s.execute("scriptui.mouse", json!({"widget": "go", "type": "mousemove"})).unwrap(); + assert!(lines(&r).is_empty()); + assert!(!s.script_ui.windows[0].root.lookup(&json!("go")).unwrap().mouse.iter().any(|m| m == "mousemove")); + s.execute("scriptui.close", json!({})).unwrap(); +} + +/// Hostile input: unknown events, disabled controls, absurd coordinates, a throwing listener, +/// a script that makes a control its own parent. +#[test] +fn mouse_events_survive_bad_input_and_throwing_listeners() { + let mut s = session(); + let code = r#" + var w = new Window("palette", "Bad"); + var b = w.add("button", undefined, "Boom", { name: "boom" }); + var off = w.add("button", undefined, "Off", { name: "off" }); + off.enabled = false; + off.addEventListener("mousedown", function () { writeLn("disabled ran"); }); + var n = 0; + b.addEventListener("mousedown", function (e) { n++; writeLn("at " + e.clientX + "," + e.clientY + " b" + e.button + " d" + e.detail); if (n === 2) throw new Error("bad press"); }); + var loop = w.add("group", undefined, undefined, { name: "loop" }); + loop.addEventListener("mousedown", function () { writeLn("loop ok"); }); + w.show(); + loop.parent = loop; + "#; + let o = run_code(&mut s, code, "bad.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert!(s.execute("scriptui.mouse", json!({"widget": "boom", "type": "dblclick"})).is_err()); + assert!(s.execute("scriptui.mouse", json!({"widget": "off", "type": "mousedown"})).is_err(), "disabled controls get no events"); + let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown", "x": 1e300, "y": -1e300, "button": 99, "detail": -4})).unwrap(); + assert_eq!(lines(&r), ["at 1000000,-1000000 b2 d0"]); + // A throwing listener reports its error; the window keeps working. + let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap(); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"]["message"].as_str().unwrap_or_default().contains("bad press"), "{r}"); + let r = s.execute("scriptui.mouse", json!({"widget": "boom", "type": "mousedown"})).unwrap(); + let at = s.script_ui.windows[0].root.lookup(&json!("boom")).unwrap().bounds; + assert_eq!(lines(&r), [format!("at {},{} b0 d1", at[2] / 2.0, at[3] / 2.0)], "x / y default to the centre"); + // A parent cycle made by the script ends instead of hanging. + let r = s.execute("scriptui.mouse", json!({"widget": "loop", "type": "mousedown"})).unwrap(); + assert!(lines(&r).iter().any(|l| l == "loop ok"), "{r}"); + s.execute("scriptui.close", json!({})).unwrap(); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 609b767b..082a16ca 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -19,6 +19,7 @@ use boa_engine::{Context, JsResult, JsValue, js_string}; use effectcraft_engine::Session; use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout}; use serde_json::{Value as J, json}; +use web_time::Instant; use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active}; @@ -132,6 +133,10 @@ pub(crate) enum ToHost { #[cfg(not(target_arch = "wasm32"))] Start(Session), Event(Session, ScriptUiEvent), + /// Run the scheduled tasks due at this time (see [`crate::tasks`]; the web has no + /// script-host threads). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + Tick(Session, Instant), } impl ToHost { @@ -140,7 +145,7 @@ impl ToHost { match self { #[cfg(not(target_arch = "wasm32"))] ToHost::Start(s) => s, - ToHost::Event(s, _) => s, + ToHost::Event(s, _) | ToHost::Tick(s, _) => s, } } } @@ -152,6 +157,8 @@ pub(crate) struct FromHost { name: String, /// The host thread has ended (no windows left). done: bool, + /// When its next scheduled task is due. + next_due: Option, } /// The host thread's end of the hand-off. @@ -164,6 +171,12 @@ pub(crate) struct Link { struct HostEnd { tx: std::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, + /// When the host's next scheduled task is due. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + next_due: Option, + /// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + epoch: u64, } fn hosts() -> &'static std::sync::Mutex> { @@ -177,38 +190,57 @@ const _: fn() = || { send::(); }; +/// What wakes a script-host thread. +enum Wake { + Event(ScriptUiEvent), + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + Tick(Instant), +} + /// On the host thread: hand the session (with the windows' state and what the script printed) -/// back to the caller, and wait for the next event. `None` when the caller has gone. +/// back to the caller, and wait for the next event. `None` when the caller has gone. Scheduled +/// tasks wait while a modal dialog does. fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) -> Option { + let mut out = outcome; + loop { + match yield_wake(out, windows.clone(), done, None)? { + Wake::Event(ev) => return Some(ev), + Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() }, + } + } +} + +/// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`. +fn yield_wake(outcome: crate::Outcome, windows: Vec, done: bool, next_due: Option) -> Option { let (session, link, name) = ACTIVE.with(|a| { let mut a = a.borrow_mut(); let a = a.as_mut()?; Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone())) })?; - if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() { + if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() { return None; } if done { return None; } - match link.rx.recv() { - Ok(ToHost::Event(s, ev)) => { - ACTIVE.with(|a| { - if let Some(a) = a.borrow_mut().as_mut() { - a.session = s; - a.link = Some(link); - } - }); - Some(ev) + let (s, wake) = match link.rx.recv() { + Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)), + Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)), + _ => return None, + }; + ACTIVE.with(|a| { + if let Some(a) = a.borrow_mut().as_mut() { + a.session = s; + a.link = Some(link); } - _ => None, - } + }); + Some(wake) } #[cfg(not(target_arch = "wasm32"))] -fn host_main(name: String, code: String, link: Link) { +fn host_main(host: u32, name: String, code: String, link: Link) { let Ok(ToHost::Start(session)) = link.rx.recv() else { return }; - let mut a = Active::new(session, &name); + let mut a = Active::new(session, &name, host); a.link = Some(link); ACTIVE.with(|c| *c.borrow_mut() = Some(a)); let file = if name.is_empty() { "script".to_string() } else { name.clone() }; @@ -220,19 +252,29 @@ fn host_main(name: String, code: String, link: Link) { return; } }; - let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file); + let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file, host); let mut out = outcome(settle(), r); + let mut had_windows = false; loop { let windows = snapshot(&mut ctx); let open = windows.iter().any(|w| w.visible); - match yield_wait(out, windows, !open) { - Some(ev) => { + had_windows |= open; + // The host lives while its windows are open, or (a script that never showed one) + // while it has scheduled tasks. + let done = !open && (had_windows || !crate::tasks::has(host)); + match yield_wake(out, windows, done, crate::tasks::next_due(host)) { + Some(Wake::Event(ev)) => { let r = dispatch_js(&mut ctx, &ev); out = outcome(settle(), r); } - None => return, + Some(Wake::Tick(now)) => { + let (ran, error) = crate::tasks::run_due(&mut ctx, host, now); + out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false }; + } + None => break, } } + crate::tasks::drop_host(host); })); // A panic: give the session back all the same. let left = ACTIVE.with(|c| c.borrow_mut().take()); @@ -241,21 +283,21 @@ fn host_main(name: String, code: String, link: Link) { { let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" }; let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() }; - let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true }); + let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None }); } } /// Wait for the host to hand the session back; keep its link while it has windows open. fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { match end.rx.recv() { - Ok(FromHost { session: s, outcome, windows, name, done }) => { + Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => { *session = s; publish(session, host, &name, windows); if done { session.script_ui.windows.retain(|w| w.host != host); session.script_ui.revision += 1; } else if let Ok(mut h) = hosts().lock() { - h.insert(host, end); + h.insert(host, HostEnd { next_due, epoch: session.script_ui.project_epoch, ..end }); } outcome } @@ -271,8 +313,10 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri let (to_tx, to_rx) = std::sync::mpsc::channel(); let (from_tx, from_rx) = std::sync::mpsc::channel(); let (name, code) = (req.name.to_string(), req.code.to_string()); - let spawned = - std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx })); + let spawned = std::thread::Builder::new() + .name(format!("script-{host}")) + .stack_size(64 << 20) + .spawn(move || host_main(host, name, code, Link { rx: to_rx, tx: from_tx })); if spawned.is_err() { return crate::runtime::run_inline(session, req); } @@ -280,7 +324,7 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri *session = msg.into_session(); return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; } - receive(session, host, HostEnd { tx: to_tx, rx: from_rx }) + receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 }) } /// Record the windows a host published (named after the script that made them). @@ -295,8 +339,11 @@ pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows: return; } session.script_ui.publish(host, windows); - if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) { + // Its last window closed: the context and its scheduled tasks go (a script that never showed + // a window keeps them, see [`crate::tasks`]). + if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) && (had || !crate::tasks::has(host)) { drop_inline(host); + crate::tasks::drop_host(host); } } @@ -330,7 +377,7 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result crate::Outcome { let s = std::mem::take(session); - ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name))); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host))); let guard = Restore { target: session }; let (r, windows) = if host == CONSOLE_HOST { CONSOLE.with(|c| match c.borrow_mut().as_mut() { @@ -354,3 +401,95 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv publish(session, host, name, windows); outcome(output, r) } + +// ---------------------------------------------------------------- scheduled tasks + +/// Run the due tasks of the script-host threads; hosts without windows whose project was closed +/// end. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { + let epoch = session.script_ui.project_epoch; + let (mut due, mut stale) = (vec![], vec![]); + if let Ok(mut h) = hosts().lock() { + for host in session.script_ui.task_hosts.clone() { + let Some(end) = h.get(&host) else { continue }; + let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); + if windowless && end.epoch != epoch { + // Dropping its end lets the host thread finish. + stale.extend(h.remove(&host)); + } else if end.next_due.is_some_and(|d| d <= now) { + due.push(host); + } + } + } + drop(stale); + for host in due { + let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue }; + if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) { + *session = msg.into_session(); + session.script_ui.windows.retain(|w| w.host != host); + session.script_ui.revision += 1; + continue; + } + report.add(receive(session, host, end)); + } + if let Ok(h) = hosts().lock() { + for host in &session.script_ui.task_hosts { + report.due(h.get(host).and_then(|end| end.next_due)); + } + } +} + +/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those +/// without windows whose project was closed stop. +pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) { + for (host, name, epoch) in crate::tasks::hosts() { + if !session.script_ui.task_hosts.contains(&host) { + continue; + } + let windowless = !session.script_ui.windows.iter().any(|w| w.host == host); + let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); + if !alive || (windowless && epoch != session.script_ui.project_epoch) { + crate::tasks::drop_host(host); + if host != CONSOLE_HOST && windowless { + drop_inline(host); + } + continue; + } + if crate::tasks::next_due(host).is_some_and(|d| d <= now) { + report.add(tick_inline_host(session, host, &name, now)); + } + if host != CONSOLE_HOST && !crate::tasks::has(host) && !session.script_ui.windows.iter().any(|w| w.host == host) { + // A script without windows whose tasks have all run. + drop_inline(host); + } + report.due(crate::tasks::next_due(host)); + } +} + +fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant) -> crate::Outcome { + let s = std::mem::take(session); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host))); + let guard = Restore { target: session }; + let run = |ctx: &mut Context| { + let r = crate::tasks::run_due(ctx, host, now); + (r, snapshot(ctx)) + }; + let ((ran, error), windows) = if host == CONSOLE_HOST { + CONSOLE.with(|c| c.borrow_mut().as_mut().map(|ctx| run(ctx))) + } else { + INLINE.with(|m| m.borrow_mut().get_mut(&host).map(|ctx| run(ctx))) + } + .unwrap_or(((0, None), vec![])); + let output = settle(); + drop(guard); + crate::tasks::set_epoch(host, session.script_ui.project_epoch); + publish(session, host, name, windows); + crate::Outcome { result: json!(ran), output, error, waiting: false } +} + +/// Forget the session's task hosts that have ended (inline ones: that have no tasks left). +pub(crate) fn prune_task_hosts(session: &mut Session) { + let threaded = |host: &u32| hosts().lock().ok().is_some_and(|h| h.contains_key(host)); + session.script_ui.task_hosts.retain(|host| crate::tasks::has(*host) || threaded(host)); +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 6bef8d63..75985fbd 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -794,10 +794,10 @@ impl<'a, 'i> Parser<'a, 'i> { let vb = viewbox_transform(n, w, h); let mut children = vec![]; let saved = (self.vw, self.vh); - if let Some((_, vw, vh)) = viewbox(n) { - self.vw = vw; - self.vh = vh; - } + // Percentages use this viewport's user space, even without a viewBox. + let (vw, vh) = viewbox(n).map_or((w, h), |(_, vw, vh)| (vw, vh)); + self.vw = vw; + self.vh = vh; for c in n.children() { self.node(c, &st, &mut children, depth + 1); } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index a7a9bef3..3553205d 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -86,6 +86,39 @@ fn paths_gradients_css_use() { assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}"); } +#[test] +fn nested_svg_percentages_use_the_nearest_viewport() { + let d = parse( + br#" + + + + + + + + + + + "#, + ) + .unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 4); + for ((_, _, shape), (name, width, height)) in + shapes.iter().zip([("inner", 20.0, 10.0), ("deep", 10.0, 5.0), ("outer", 50.0, 10.0), ("viewbox", 100.0, 50.0)]) + { + assert_eq!(shape.name, name); + assert!(matches!(shape.geom, Geom::Rect { w, h, .. } if w == width && h == height), "{name}: {:?}", shape.geom); + } + let img = rasterize(&d, 100, 80, 1.0); + assert_eq!(px(&img, 15, 15), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(px(&img, 35, 12), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(px(&img, 35, 17)[3], 0.0); + assert_eq!(px(&img, 45, 65)[3], 1.0); + assert_eq!(px(&img, 55, 65)[3], 0.0); +} + #[test] fn malformed_input() { assert!(parse(b"").is_err()); diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs new file mode 100644 index 00000000..39714cad --- /dev/null +++ b/crates/text/src/font_dirs.rs @@ -0,0 +1,374 @@ +//! The font folders of a Linux/BSD system, found the way the desktop finds them: the usual +//! folders, the XDG data folders (`$XDG_DATA_HOME/fonts`, `$XDG_DATA_DIRS/*/fonts`), the host font +//! folders a Flatpak sandbox exposes under `/run/host`, and the `` entries of the fontconfig +//! configuration (`/etc/fonts/fonts.conf` with its includes, `~/.config/fontconfig/fonts.conf`). +//! +//! The fontconfig files are read with a small tag scanner (no fontconfig library, no XML crate): +//! only ``, `` and `` matter here. The work is bounded: include depth, +//! number and size of the files read, and every file is read at most once. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// The environment the folders depend on, passed in so tests don't touch the process environment. +#[derive(Clone, Debug, Default)] +pub(crate) struct FontEnv { + pub home: Option, + pub xdg_data_home: Option, + pub xdg_data_dirs: Option, + pub xdg_config_home: Option, + /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. + pub fontconfig_file: Option, + /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). + pub fontconfig_roots: Vec, + /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). + pub fixed: Vec, +} + +impl FontEnv { + pub fn from_process() -> Self { + let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()); + Self { + home: var("HOME").map(PathBuf::from), + xdg_data_home: var("XDG_DATA_HOME").map(PathBuf::from), + xdg_data_dirs: var("XDG_DATA_DIRS"), + xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), + fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), + fixed: [ + "/usr/share/fonts", + "/usr/local/share/fonts", + // Flatpak: the host's system, local and user fonts (no filesystem permission needed). + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/user-fonts", + ] + .map(PathBuf::from) + .to_vec(), + } + } + + fn data_home(&self) -> Option { + self.xdg_data_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".local/share"))) + } + + fn config_home(&self) -> Option { + self.xdg_config_home.clone().filter(|p| p.is_absolute()).or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + } +} + +/// Every font folder for `env`, without duplicates, in a stable order. Folders that don't exist +/// are kept (the scan skips them). +pub(crate) fn unix_font_dirs(env: &FontEnv) -> Vec { + let mut dirs = env.fixed.clone(); + if let Some(h) = &env.home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + if let Some(d) = env.data_home() { + dirs.push(d.join("fonts")); + } + let data_dirs = env.xdg_data_dirs.clone().unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(std::env::split_paths(&data_dirs).filter(|p| p.is_absolute()).map(|p| p.join("fonts"))); + dirs.extend(fontconfig_dirs(env)); + dedup_dirs(dirs) +} + +/// Drop repeated folders (also the same folder reached through a symlink), keeping the first. +fn dedup_dirs(dirs: Vec) -> Vec { + let mut seen = HashSet::new(); + dirs.into_iter().filter(|d| seen.insert(std::fs::canonicalize(d).unwrap_or_else(|_| d.clone()))).collect() +} + +const MAX_INCLUDE_DEPTH: u32 = 8; +const MAX_FILES: usize = 512; +const MAX_FILE_BYTES: u64 = 1 << 20; + +/// The font folders named by the fontconfig configuration. +pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { + let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; + let root = match &env.fontconfig_file { + Some(f) => Some(f.clone()), + None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), + }; + if let Some(root) = root { + st.file(&root, 0); + } + // The user's own configuration, in case the system one doesn't include it. + if let Some(c) = env.config_home() { + st.include(&c.join("fontconfig/fonts.conf"), 0); + st.include(&c.join("fontconfig/conf.d"), 0); + } + st.out +} + +struct Reader<'a> { + env: &'a FontEnv, + out: Vec, + seen: HashSet, +} + +impl Reader<'_> { + /// Read a configuration file or, for a folder, its `NN-name.conf` files in name order. + fn include(&mut self, path: &Path, depth: u32) { + if path.is_dir() { + let Ok(rd) = std::fs::read_dir(path) else { return }; + let mut files: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".conf") && n.starts_with(|c: char| c.is_ascii_digit()))) + .collect(); + files.sort(); + for f in files { + self.file(&f, depth); + } + } else { + self.file(path, depth); + } + } + + fn file(&mut self, path: &Path, depth: u32) { + if depth > MAX_INCLUDE_DEPTH || self.seen.len() >= MAX_FILES || !path.is_file() { + return; + } + let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + if !self.seen.insert(key) { + return; + } + if std::fs::metadata(path).map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { return }; + let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); + for el in elements(&text) { + match el.name { + "dir" | "remap-dir" => { + if let Some(p) = self.resolve(&el, &base, false) { + self.out.push(p); + } + } + "include" => { + if let Some(p) = self.resolve(&el, &base, true) { + self.include(&p, depth.saturating_add(1)); + } + } + _ => {} + } + } + } + + /// The path an element names: `~` is the home folder, `prefix="xdg"` the XDG data (folders) + /// or config (includes) home, `prefix="relative"` and plain relative paths the including + /// file's folder, `prefix="default"`/`"cwd"` the working folder. + fn resolve(&self, el: &Element, base: &Path, include: bool) -> Option { + let v = el.text.trim(); + if v.is_empty() { + return None; + } + if let Some(rest) = v.strip_prefix('~') { + return Some(self.env.home.as_ref()?.join(rest.trim_start_matches('/'))); + } + let p = Path::new(v); + if p.is_absolute() { + return Some(p.to_path_buf()); + } + match el.attr("prefix") { + Some("xdg") => Some(if include { self.env.config_home()? } else { self.env.data_home()? }.join(p)), + Some("default" | "cwd") => std::env::current_dir().ok().map(|c| c.join(p)), + _ => Some(base.join(p)), + } + } +} + +struct Element<'a> { + name: &'a str, + attrs: &'a str, + text: String, +} + +impl Element<'_> { + fn attr(&self, key: &str) -> Option<&str> { + let mut rest = self.attrs; + while let Some(eq) = rest.find('=') { + let k = rest.get(..eq)?.trim(); + let after = rest.get(eq + 1..)?.trim_start(); + let q = after.chars().next()?; + if q != '"' && q != '\'' { + return None; + } + let body = after.get(1..)?; + let end = body.find(q)?; + if k == key { + return body.get(..end); + } + rest = body.get(end + 1..)?; + } + None + } +} + +/// The ``, `` and `` elements of a fontconfig file, comments skipped. +fn elements(src: &str) -> Vec> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(off) = src.get(i..).and_then(|s| s.find('<')) { + let start = i + off; + let Some(tail) = src.get(start..) else { break }; + if tail.starts_with("") { + Some(e) => { + i = start + e + 3; + continue; + } + None => break, + } + } + let Some(close) = tail.find('>') else { break }; + let inner = tail.get(1..close).unwrap_or_default(); + i = start + close + 1; + let name_len = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len()); + let name = inner.get(..name_len).unwrap_or_default(); + if !matches!(name, "dir" | "remap-dir" | "include") || inner.ends_with('/') { + continue; + } + let attrs = inner.get(name_len..).unwrap_or_default(); + let end_tag = format!(""); + let Some(body_len) = src.get(i..).and_then(|s| s.find(&end_tag)) else { break }; + let body = src.get(i..i + body_len).unwrap_or_default(); + i += body_len + end_tag.len(); + out.push(Element { name, attrs, text: decode_entities(body) }); + } + out +} + +fn decode_entities(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-font-dirs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn env_in(root: &Path) -> FontEnv { + FontEnv { + home: Some(root.join("home")), + fontconfig_roots: vec![root.join("etc/fonts/fonts.conf")], + xdg_data_dirs: Some("/nonexistent-share".into()), + ..Default::default() + } + } + + fn write(p: &Path, s: &str) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, s).unwrap(); + } + + #[test] + fn fontconfig_dirs_follow_includes_prefixes_and_skip_the_rest() { + let root = temp("conf"); + let etc = root.join("etc/fonts"); + write( + &etc.join("fonts.conf"), + r#" + + + + /sys/fonts + fonts + ~/.fonts + /var/cache/fontconfig + fontconfig + local-fonts + /a&b/fonts + conf.d + missing.conf + fontconfig/fonts.conf + fonts.conf +"#, + ); + write( + &etc.join("conf.d/50-flatpak.conf"), + "/run/host/fonts/run/host/font-dirs.xml", + ); + write(&etc.join("conf.d/60-remap.conf"), "/remapped/fonts"); + write(&etc.join("conf.d/README"), "/not/a/conf/file"); + write(&etc.join("conf.d/nodigit.conf"), "/no/digit"); + write(&root.join("home/.config/fontconfig/fonts.conf"), "/user/conf/fonts"); + let dirs = fontconfig_dirs(&env_in(&root)); + let home = root.join("home"); + let want = vec![ + PathBuf::from("/sys/fonts"), + home.join(".local/share/fonts"), + home.join(".fonts"), + etc.join("local-fonts"), + PathBuf::from("/a&b/fonts"), + PathBuf::from("/run/host/fonts"), + PathBuf::from("/remapped/fonts"), + PathBuf::from("/user/conf/fonts"), + ]; + assert_eq!(dirs, want); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn include_cycles_and_unclosed_tags_terminate() { + let root = temp("cycle"); + let etc = root.join("etc/fonts"); + write(&etc.join("fonts.conf"), "/oneb.conffonts.conf"); + write(&etc.join("b.conf"), "fonts.conf/two/unclosed"); + let dirs = fontconfig_dirs(&env_in(&root)); + assert_eq!(dirs, vec![PathBuf::from("/one"), PathBuf::from("/two")]); + assert!(fontconfig_dirs(&FontEnv::default()).is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn xdg_and_flatpak_folders_are_listed_once() { + let root = temp("xdg"); + let env = FontEnv { + home: Some(root.join("home")), + xdg_data_home: Some(root.join("data")), + xdg_data_dirs: Some(std::env::join_paths([root.join("share"), "relative/ignored".into()]).unwrap()), + fixed: vec![PathBuf::from("/run/host/fonts")], + fontconfig_file: Some(root.join("fonts.conf")), + ..Default::default() + }; + write(&root.join("fonts.conf"), "/run/host/fontsfonts"); + let dirs = unix_font_dirs(&env); + assert_eq!( + dirs, + vec![ + PathBuf::from("/run/host/fonts"), + root.join("home/.local/share/fonts"), + root.join("home/.fonts"), + root.join("data/fonts"), + root.join("share/fonts"), + ] + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// A font in a folder that only the fontconfig configuration names (as in a Flatpak sandbox, + /// where the host's fonts are under /run/host) is found by the scan. + #[test] + fn a_font_named_only_by_fontconfig_is_scanned() { + use crate::fonts::FontSource; + let root = temp("e2e"); + let host = root.join("run/host/fonts/TTF"); + std::fs::create_dir_all(&host).unwrap(); + std::fs::write(host.join("NOTOSERIF.TTF"), crate::fonts::NOTO_SERIF_REGULAR).unwrap(); + write(&root.join("etc/fonts/conf.d/50-flatpak.conf"), &format!("{}", root.join("run/host/fonts").display())); + write(&root.join("etc/fonts/fonts.conf"), "conf.d"); + let src = crate::fonts::DirectorySource { dirs: unix_font_dirs(&env_in(&root)) }; + let faces = src.faces(); + assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2f7311c5..4091344d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -58,6 +58,8 @@ pub struct FaceInfo { /// be requested the way After Effects scripts name fonts. pub full_name: String, pub postscript: Option, + /// The face has variation axes (an `fvar` table), read from the table directory at scan time. + pub variable: bool, } /// Something that can list fonts (system folders, a web font picker, a project's font folder…). @@ -87,13 +89,11 @@ impl DirectorySource { if let Some(l) = std::env::var_os("LOCALAPPDATA") { dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); } - } else if cfg!(unix) && !cfg!(target_arch = "wasm32") { - dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); - if let Some(h) = &home { - dirs.push(h.join(".local/share/fonts")); - dirs.push(h.join(".fonts")); - } } + // Linux/BSD: the usual folders, the XDG data folders, a Flatpak sandbox's host font + // folders (/run/host/…) and the folders the fontconfig configuration names. + #[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] + dirs.extend(crate::font_dirs::unix_font_dirs(&crate::font_dirs::FontEnv::from_process())); dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref())); Self { dirs } } @@ -110,7 +110,8 @@ fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { for e in rd.flatten() { let p = e.path(); if p.is_dir() { - if depth < 4 { + // Bounded, so a symlink loop ends. + if depth < 8 { walk(&p, depth + 1, out); } } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { @@ -150,6 +151,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { native_family: n.native_family, full_name: n.full_name, postscript: n.postscript, + variable: n.variable, } } @@ -403,6 +405,13 @@ pub fn native_families() -> std::collections::BTreeMap { all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect() } +/// Families with at least one variable face (variation axes), for the font menu's Variable +/// Fonts filter. Scans the system fonts first if that hasn't happened yet. +pub fn variable_families() -> std::collections::BTreeSet { + scan_system(); + all_faces().into_iter().filter(|f| f.info.variable).map(|f| f.info.family.clone()).collect() +} + /// Families with their style names, sorted by family; styles in weight order. This is what font /// menus list, so it scans the system fonts first if that hasn't happened yet (before, only a /// project asking for a font the bundled ones lack started the scan, and the menus offered the diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 21bb12b1..8b7ca771 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))] +mod font_dirs; pub mod fonts; pub mod layout; pub mod path_text; diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index 7acfbd38..ec9763bf 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -19,6 +19,9 @@ pub struct FaceNames { /// The PostScript name (name id 6, e.g. `YuGothic-Bold`): what After Effects scripts and /// `.aep`-derived data call a font. pub postscript: Option, + /// A variable font: it has an `fvar` table with at least one axis (the font menu's + /// Variable Fonts filter). + pub variable: bool, } fn be16(b: &[u8], o: usize) -> Option { @@ -66,12 +69,14 @@ fn read_face(r: &mut R, off: u64, index: u32) -> Option name = Some((o, l)), b"OS/2" => os2 = Some((o, l)), + b"fvar" => fvar = Some((o, l)), _ => {} } } @@ -92,7 +97,9 @@ fn read_face(r: &mut R, off: u64, index: u32) -> Option= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0); + Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable }) } /// A Windows Unicode name-table string by id in a language other than US English. @@ -165,5 +172,57 @@ mod tests { let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR); assert_eq!(n[0].family, "Noto Serif"); assert!(read_faces_bytes(b"not a font").is_empty()); + assert!(!f[0].variable && !n[0].variable, "the bundled fonts are static"); + } + + /// A font file of just `name` (family, Regular) and, when `fvar` is given, an `fvar` table + /// with that many axes (or a truncated one with `Some(None)`). + fn names_font(family: &str, fvar: Option>) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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 tables: Vec<(&[u8; 4], Vec)> = vec![]; + match fvar { + Some(Some(n)) => { + // Header, then `n` axis records: `wght` pinned at 400 (min = default = max). + let mut t = u16s(&[1, 0, 16, 2, n, 20, 0, 4 + 4 * n]); + (0..n).for_each(|_| t.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])))); + tables.push((b"fvar", t)); + } + Some(None) => tables.push((b"fvar", u16s(&[1, 0, 16]))), + None => {} + } + tables.push((b"name", name)); + let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]); + let mut offset = 12 + 16 * tables.len(); + let mut body: Vec = vec![]; + for (tag, data) in &tables { + out.extend(*tag); + out.extend([0u8; 4]); + out.extend((offset as u32).to_be_bytes()); + out.extend((data.len() as u32).to_be_bytes()); + body.extend(data); + offset += data.len(); + } + out.extend(body); + out + } + + /// The scan marks a face with an `fvar` table that declares axes as variable, reading only + /// the table directory and the fvar header; a static font, an fvar without axes and a + /// truncated fvar are not. + #[test] + fn detects_variable_faces_from_fvar() { + let v = read_faces_bytes(&names_font("Var Test", Some(Some(1)))); + assert_eq!((v.len(), v[0].family.as_str(), v[0].variable), (1, "Var Test", true), "{v:?}"); + assert!(!read_faces_bytes(&names_font("Static Test", None))[0].variable); + assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable); + assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 6594e2dc..a673578e 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -197,26 +197,53 @@ mod tests { eprintln!("no variable font installed: skipped"); return; }; - // An axis that changes widths (wdth or wght, else the first that moves). - let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]); let info = crate::fonts::face(face).info.clone(); - let st = |v: f32| crate::TextStyle { + // An axis that moves the advance: `wdth` and `wght` usually do, but a monospaced family + // keeps its advances fixed along `wght` (Google Sans Code does) and moves them along its + // own axis instead, so try the axes in order and take the first one that moves. A face + // with no axis carrying a range is the only case that skips: a variation path that broke + // completely moves neither the advances nor the outlines, and that has to fail below. + let st = |a: &FontAxis, v: f32| crate::TextStyle { family: info.family.clone(), style: info.style.clone(), size: 100.0, variations: vec![(a.tag.clone(), v)], ..Default::default() }; - let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max))); + let ranged: Vec<&FontAxis> = axes.iter().filter(|a| a.max > a.min).collect(); + if ranged.is_empty() { + eprintln!("no variable axis installed here carries a range: skipped"); + return; + } + let mut moving = None; + for a in &ranged { + let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a, a.min)), crate::layout::measure("Hamburgefonts", &st(a, a.max))); + if (lo - hi).abs() > 1.0 { + moving = Some(((*a).clone(), lo, hi)); + break; + } + } if crate::resolve(&info.family, &info.style).face != face { eprintln!("variable face not reachable by family/style: skipped"); return; } - assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max); - let ink = |v: f32| { - let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default()); + let ink = |a: &FontAxis, v: f32| { + let l = crate::layout::layout("H", &st(a, v), &crate::ParagraphStyle::default()); l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::() }; - assert!((ink(a.min) - ink(a.max)).abs() > 1.0); + if let Some((a, lo, hi)) = moving { + // The axis was picked because it moves the advance; the outline has to follow it too. + assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max); + let (i_lo, i_hi) = (ink(&a, a.min), ink(&a, a.max)); + assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: outlines do not move", a.tag, a.min, a.max); + } else { + // Nothing here moves an advance. Holding the advances is what a monospaced family does + // on purpose, but it is also what a broken variation path does, so this case still has + // to fail rather than skip: the outline must move along `wght`, or, on a face without + // one, along the first axis that carries a range. + let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).unwrap_or(ranged[0]); + let (i_lo, i_hi) = (ink(a, a.min), ink(a, a.max)); + assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: neither the advances nor the outlines move, so variation support looks broken", a.tag, a.min, a.max); + } } } diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index cd24dbc0..35d8e4eb 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -41,7 +41,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); }); - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape is the picker's: consume it, so nothing later this pass takes it too. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { out.close = Some(false); } if pressed_outside(ui.ctx(), &area.response) { @@ -55,11 +56,30 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) ui.data_mut(|d| { d.insert_temp(id.with("open"), false); d.remove::<[f32; 3]>(orig_id); + // Closed: the next Escape is the dialog's again. + d.remove::(open_pass_id()); }); + } else { + // Still open after this pass: dialogs leave Escape to the picker (see `open_recently`). + let pass = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| d.insert_temp(open_pass_id(), pass)); } out.changed } +fn open_pass_id() -> egui::Id { + egui::Id::new("color-picker-open-pass") +} + +/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one +/// that stops being drawn without closing, its dialog gone, doesn't count for long). Escape then +/// belongs to the picker (it goes back to the original colour and closes), not to the dialog it +/// was opened from. Closing clears this at once, so the next Escape reaches the dialog. +pub fn open_recently(ctx: &egui::Context) -> bool { + let pass: Option = ctx.data(|d| d.get_temp(open_pass_id())); + pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr()) +} + /// What the picker did this frame. #[derive(Default)] struct Outcome { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 724f3402..113c8bc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -223,8 +223,16 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(z) = v.get("zoom") { app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); } + // The active comp's Resolution (`view.res.*`), saved with the project. if let Some(r) = v.get("res").and_then(Value::as_str) { - app.ui.viewer.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).unwrap_or(app.ui.viewer.res); + let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else { + return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)")); + }; + if let Some(cid) = app.session.active_comp_id() + && let Err(e) = app.session.set_resolution(cid, res) + { + return err(e); + } } if let Some(Value::Array(a)) = v.get("pan") { app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32]; @@ -308,6 +316,9 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques Err(e) => return err(e), }; let m = modifiers(p); + // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) + // and the synthetic events arrive one per frame: hold them for the whole gesture. + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -321,6 +332,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -332,6 +344,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -339,6 +352,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -522,11 +536,16 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { let automation_id = app.auto.elements.iter().chain(app.auto.previous.iter()).find(|e| e.response_id == Some(id)).map(|e| e.id.as_str()); json!({"id": format!("{:016x}", id.value()), "rect": rect, "automationId": automation_id, "registryCandidates": candidates}) }); + let mut ui = json!(app.ui); + // The viewer's Resolution is the active comp's (kept in the project). + if let Some(v) = ui.get_mut("viewer").and_then(Value::as_object_mut) { + v.insert("res".into(), json!(app.viewer_res())); + } json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, - "ui": app.ui, + "ui": ui, "playing": app.playback.playing, "time": app.session.time().seconds(), "activeComp": app.session.state.active_comp.map(|i| i.0), diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 3e802487..80d68f8e 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -670,6 +670,37 @@ pub struct Floating { pub rect: [f32; 4], } +/// A floating group moved out of the main window into its own native window (which can sit on +/// another monitor): the group holding `panel` shows there. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PanelWindow { + pub panel: PanelKind, + /// The window's [x, y] outer position and [w, h] inner size, in desktop points. + pub rect: [f32; 4], +} + +/// The smallest and largest native panel window, in points. +pub const PANEL_WINDOW_MIN: [f32; 2] = [160.0, 100.0]; +pub const PANEL_WINDOW_MAX: f32 = 16_384.0; +/// The farthest a native panel window may sit from the desktop origin, in points. +pub const PANEL_WINDOW_MAX_POS: f32 = 100_000.0; + +impl PanelWindow { + /// The rect made safe to hand to the windowing system: finite, with a size within + /// [`PANEL_WINDOW_MIN`]..=[`PANEL_WINDOW_MAX`] and a position within + /// ±[`PANEL_WINDOW_MAX_POS`] (hostile workspace files and bad monitor reports included). + pub fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(y, 0.0).clamp(-PANEL_WINDOW_MAX_POS, PANEL_WINDOW_MAX_POS), + finite(w, 420.0).clamp(PANEL_WINDOW_MIN[0], PANEL_WINDOW_MAX), + finite(h, 320.0).clamp(PANEL_WINDOW_MIN[1], PANEL_WINDOW_MAX), + ] + } +} + impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s @@ -1305,10 +1336,13 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } } } - // clicking anywhere in the panel focuses it + // clicking anywhere in the panel focuses it; a press on another of its tabs waits for that + // tab's click, which focuses the panel actually chosen (a text edit stays open in Character) + let at = ui.input(|i| i.pointer.interact_pos()); if let Some(p) = active_panel && ui.rect_contains_pointer(g.rect) && ui.input(|i| i.pointer.any_pressed()) + && !out.tabs.iter().any(|(q, r)| *q != p && at.is_some_and(|a| r.contains(a))) { out.actions.push(DockAction::Focus(p)); } diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9c28f353..de971bdc 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -2,11 +2,13 @@ //! and body, floating (undocked) panel groups over it, and tab drag-and-drop: drag a tab onto a //! group to tab it there (center) or split it off to a side (After Effects' drop zones, with a //! highlight of where it will land); hold Cmd/Ctrl when releasing, or drop outside every group, -//! to float it. `` ` `` maximizes the panel under the pointer. +//! to float it. `` ` `` maximizes the panel under the pointer. A floating group can also move +//! out of the main window into its own native window ([`EffectcraftApp::set_panel_window`]), +//! which can sit on another monitor. use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind}; +use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, PanelWindow}; use crate::{EffectcraftApp, panels}; use craft_ui::docking::{Action, DropTarget, resolve_drop}; @@ -245,7 +247,15 @@ impl EffectcraftApp { }) .unwrap_or(usize::MAX) }); + // A native window is kept only for a panel that still floats. + let ui_state = &mut self.ui; + ui_state.panel_windows.retain(|w| ui_state.floating.iter().any(|group| group.panels.contains(&w.panel))); for i in floating_order { + // Groups in their own native window take no tab drops from the main window. + if let Some(window) = self.ui.floating.get(i).and_then(|group| self.panel_window_of(&group.panels)) { + self.panel_window(&ctx, i, window, &info, &mut actions); + continue; + } if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) { tab_rects.push((g.path.clone(), tabs)); float_groups.push(g); @@ -264,6 +274,7 @@ impl EffectcraftApp { } DockAction::Focus(p) => { self.ui.focused = p; + panels::viewer_text::panel_clicked(self, &ctx, p); // A click in (or on the tab of) another Composition viewer makes it active. if let Some(id) = panels::viewers::id_of(p) { panels::viewers::activate(self, &ctx, id); @@ -376,6 +387,135 @@ impl EffectcraftApp { group } + /// The native window a floating group (its `panels`) shows in, if any. + pub(crate) fn panel_window_of(&self, panels: &[PanelKind]) -> Option { + self.ui.panel_windows.iter().find(|w| panels.contains(&w.panel)).copied() + } + + /// Whether panel `p` shows in its own native window. + pub fn in_panel_window(&self, p: PanelKind) -> bool { + self.ui.floating.iter().any(|group| group.panels.contains(&p) && self.panel_window_of(&group.panels).is_some()) + } + + /// The viewport panel `p` is drawn in: its native window's, or the main window's (also when + /// the platform embeds viewports, as the browser build does). + pub(crate) fn panel_viewport(&self, ctx: &egui::Context, p: PanelKind) -> egui::ViewportId { + if ctx.embed_viewports() { + return egui::ViewportId::ROOT; + } + self.ui + .floating + .iter() + .find(|group| group.panels.contains(&p)) + .and_then(|group| self.panel_window_of(&group.panels)) + .map_or(egui::ViewportId::ROOT, |w| panel_window_id(w.panel)) + } + + /// Move panel `p` (and the floating group it is in) into its own native window, which can be + /// placed on another monitor, or back into the main window (`window` false; it floats there + /// where it was before). `None` toggles. A docked panel is undocked first. The window opens + /// over the place the panel had in the main window. Returns whether `p` is now in a window. + pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option) -> Result { + let group_of = |app: &Self| app.ui.floating.iter().find(|group| group.panels.contains(&p)).map(|group| (group.panels.clone(), group.rect)); + let current = self.in_panel_window(p); + let window = window.unwrap_or(!current); + if window == current { + return Ok(current); + } + if !window { + if let Some((panels, _)) = group_of(self) { + self.ui.panel_windows.retain(|w| !panels.contains(&w.panel)); + } + return Ok(false); + } + if group_of(self).is_none() { + if !self.ui.dock.contains(p) { + return Err(format!("{} is not open", p.title())); + } + let rect = default_float_rect(ctx.input_for(egui::ViewportId::ROOT, |i| i.content_rect())); + if !self.edit_layout(|layout| layout.float(p, rect)) { + return Err(format!("can't undock {}", p.title())); + } + } + let Some((_, [x, y, w, h])) = group_of(self) else { return Err(format!("can't undock {}", p.title())) }; + // Over the panel's place in the main window (its content's position on the desktop). + let origin = ctx.input_for(egui::ViewportId::ROOT, |i| i.viewport().inner_rect.map(|r| r.min)).unwrap_or(pos2(0.0, 0.0)); + let rect = PanelWindow::sanitized([origin.x + x, origin.y + y, w, h]); + ctx.data_mut(|d| d.remove::<[f32; 4]>(panel_window_spawn_id(p))); + self.ui.panel_windows.push(PanelWindow { panel: p, rect }); + if self.ui.maximized == Some(p) { + self.ui.maximized = None; + } + Ok(true) + } + + /// One floating group in its own native window: the group's tab strip and active panel fill + /// the window. Closing the window brings the group back into the main window. + fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo, actions: &mut Vec) { + let t = self.tokens; + let Some(f) = self.ui.floating.get(i).cloned() else { return }; + let active = f.active.min(f.panels.len().saturating_sub(1)); + let Some(shown) = f.panels.get(active).copied() else { return }; + // The window opens at its saved place; after that the window system owns its position and + // size (handing them back every frame would fight the user dragging it). + let spawn_id = panel_window_spawn_id(window.panel); + let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_id, || PanelWindow::sanitized(window.rect))); + let builder = egui::ViewportBuilder::default() + .with_title((info.title)(shown)) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(dock::PANEL_WINDOW_MIN); + let mut closed = false; + let mut placed = None; + ctx.show_viewport_immediate(panel_window_id(window.panel), builder, |ui, class| { + let native = matches!(class, egui::ViewportClass::Immediate | egui::ViewportClass::Deferred); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| { + let r = ui.max_rect(); + let g = Group { + path: format!("w{i}"), + rect: r, + content: Rect::from_min_max(pos2(r.min.x, (r.min.y + t.tab_h).min(r.max.y)), r.max), + panels: f.panels.clone(), + active, + stacked: None, + }; + let c = dock::draw_group_chrome(ui, &g, &t, &mut self.auto, info); + // Tabs can't be dragged between native windows yet. + actions.extend(c.actions.into_iter().filter(|a| !matches!(a, DockAction::BeginDrag(_)))); + self.panel_body(ui, shown, g.content); + if shown == self.ui.focused { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + if native { + panels::panel_menu_popup(self, ui); + } + }); + // An embedded window (no native windows on this platform) reads the main window's + // input: its close request and place aren't this window's. + if native { + closed = ui.input(|i| i.viewport().close_requested()); + placed = ui.input(|i| { + let v = i.viewport(); + let inner = v.inner_rect?; + let min = v.outer_rect.map_or(pos2(window.rect[0], window.rect[1]), |outer| outer.min); + Some([min.x, min.y, inner.width(), inner.height()]) + }); + } + }); + if closed { + self.ui.panel_windows.retain(|w| !f.panels.contains(&w.panel)); + ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_id)); + return; + } + // Remember where the window is, so the workspace reopens it there. + if let Some(rect) = placed.map(PanelWindow::sanitized) + && rect != window.rect + && let Some(w) = self.ui.panel_windows.iter_mut().find(|w| w.panel == window.panel) + { + w.rect = rect; + } + } + /// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab /// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs /// too); near a group's edge, the half it will split off. On release it lands there (or @@ -460,6 +600,16 @@ fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) { } } +/// The native window of the floating group whose window entry names `panel`. +pub fn panel_window_id(panel: PanelKind) -> egui::ViewportId { + egui::ViewportId::from_hash_of(("effectcraft-panel-window", panel.id())) +} + +/// Where a native panel window was told to open (kept for as long as it stays open). +fn panel_window_spawn_id(panel: PanelKind) -> egui::Id { + egui::Id::new(("panel-window-spawn", panel.id())) +} + /// A new floating group's default rect near the centre of `screen`. pub fn default_float_rect(screen: Rect) -> [f32; 4] { let c = screen.center(); diff --git a/crates/ui-egui/src/expression_diagnostics.rs b/crates/ui-egui/src/expression_diagnostics.rs new file mode 100644 index 00000000..93d2495d --- /dev/null +++ b/crates/ui-egui/src/expression_diagnostics.rs @@ -0,0 +1,479 @@ +//! Cached viewer diagnostics. Native scans run on one persistent worker, never while painting. +//! Each viewer keeps only its newest request; obsolete scans stop between properties. On the +//! single-threaded web build, one shared scan runs after interaction settles (no thread fallback). + +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::expr_tools::{self, ExprError}; +use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::render::{EvalCtx, ExprHost, FootageSource}; +use effectcraft_engine::time::Tick; +use web_time::Instant; + +const QUIET: Duration = Duration::from_millis(150); +const MAX_VIEWERS: usize = 8; +type Errors = Arc<[ExprError]>; +type SyntaxCheck = fn(&str) -> Result<(), String>; + +#[derive(Clone)] +struct Snapshot { + project: Arc, + revision: u64, + comp: ItemId, + time: Tick, + expr: Option>, + footage: Arc, + check: Option, +} + +impl Snapshot { + fn new(s: &Session, comp: ItemId) -> Self { + Self { + project: s.project.clone(), + revision: s.revision, + comp, + time: s.time_of(comp), + expr: s.expr.clone(), + footage: s.footage.clone(), + check: s.expr_check, + } + } + + fn matches(&self, s: &Session, comp: ItemId) -> bool { + self.revision == s.revision && self.comp == comp && self.time == s.time_of(comp) && Arc::ptr_eq(&self.project, &s.project) + } +} + +struct Request { + snapshot: Snapshot, + cancelled: Arc, + #[cfg(not(target_arch = "wasm32"))] + repaint: egui::Context, +} + +struct Completed { + #[cfg(not(target_arch = "wasm32"))] + comp: ItemId, + #[cfg(not(target_arch = "wasm32"))] + cancelled: Arc, + result: Result>, String>, +} + +impl Request { + fn run(&self) -> Completed { + let s = &self.snapshot; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let comp = s.project.comp(s.comp)?; + let ctx = EvalCtx { expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()), ..EvalCtx::new(&s.project, s.comp, comp, s.time) }; + expr_tools::errors_in(&ctx, s.check, || !self.cancelled.load(Ordering::Relaxed)) + })) + .map_err(|_| "Expression diagnostics stopped unexpectedly; edit an expression to retry.".to_string()); + Completed { + #[cfg(not(target_arch = "wasm32"))] + comp: s.comp, + #[cfg(not(target_arch = "wasm32"))] + cancelled: self.cancelled.clone(), + result, + } + } +} + +struct Entry { + snapshot: Snapshot, + changed: Instant, + cancelled: Arc, + submitted: bool, + errors: Errors, +} + +impl Drop for Entry { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub(crate) struct Diagnostics { + entries: VecDeque, + #[cfg(not(target_arch = "wasm32"))] + worker: Option, + failure: Option, +} + +impl Diagnostics { + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub fn take_failure(&mut self) -> Option { + self.failure.take() + } + + /// Paint from completed results only. Changes discard stale results immediately, including + /// errors whose layer/property no longer exists. Pointer interaction and playback defer scans. + pub fn errors(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool) -> Errors { + self.update(s, ctx, comp, interacting, Instant::now()) + } + + fn update(&mut self, s: &Session, ctx: &egui::Context, comp: ItemId, interacting: bool, now: Instant) -> Errors { + if !s.prefs.scripting.error_banner || s.expr.is_none() { + self.clear(); + return Arc::from([]); + } + let mut entry = self.entries.iter().position(|e| e.snapshot.comp == comp).and_then(|i| self.entries.remove(i)); + if entry.as_ref().is_none_or(|e| !e.snapshot.matches(s, comp)) { + entry = Some(Entry { + snapshot: Snapshot::new(s, comp), + changed: now, + cancelled: Arc::new(AtomicBool::new(false)), + submitted: false, + errors: Arc::from([]), + }); + } + let Some(mut entry) = entry else { return Arc::from([]) }; + if interacting { + entry.changed = now; + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(worker) = &self.worker { + let done: Vec<_> = worker.shared.completed.lock().unwrap_or_else(PoisonError::into_inner).drain(..).collect(); + for done in done { + let target = if done.comp == comp { Some(&mut entry) } else { self.entries.iter_mut().find(|e| e.snapshot.comp == done.comp) }; + if let Some(target) = target + && Arc::ptr_eq(&target.cancelled, &done.cancelled) + && !done.cancelled.load(Ordering::Relaxed) + { + match done.result { + Ok(Some(errors)) => target.errors = errors.into(), + Ok(None) => {} + Err(e) => self.failure = Some(e), + } + } + } + } + if !entry.submitted && !interacting { + let quiet = now.saturating_duration_since(entry.changed); + if quiet < QUIET { + ctx.request_repaint_after(QUIET - quiet); + } else { + entry.submitted = true; + let request = Request { + snapshot: entry.snapshot.clone(), + cancelled: entry.cancelled.clone(), + #[cfg(not(target_arch = "wasm32"))] + repaint: ctx.clone(), + }; + #[cfg(not(target_arch = "wasm32"))] + { + if self.worker.is_none() { + match Worker::new() { + Ok(worker) => self.worker = Some(worker), + Err(e) => self.failure = Some(format!("Could not start expression diagnostics: {e}")), + } + } + if let Some(worker) = &self.worker { + worker.submit(request); + } + } + // wasm32 cannot spawn Rust threads. Preserve diagnostics there, but eliminate + // the duplicate scan and defer it until scrubbing/dragging/playback stops. + #[cfg(target_arch = "wasm32")] + match request.run().result { + Ok(Some(errors)) => entry.errors = errors.into(), + Ok(None) => {} + Err(e) => self.failure = Some(e), + } + } + } + let result = entry.errors.clone(); + self.entries.push_back(entry); + if self.entries.len() > MAX_VIEWERS { + self.entries.pop_front(); + } + result + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct Shared { + pending: Mutex>, + completed: Mutex>, + wake: std::sync::Condvar, + stopped: AtomicBool, +} + +#[cfg(not(target_arch = "wasm32"))] +struct Worker { + shared: Arc, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Worker { + fn new() -> std::io::Result { + let shared = Arc::new(Shared::default()); + let work = shared.clone(); + std::thread::Builder::new().name("ec-expression-diagnostics".into()).spawn(move || { + loop { + let request = { + let mut pending = work.pending.lock().unwrap_or_else(PoisonError::into_inner); + while pending.is_empty() && !work.stopped.load(Ordering::Relaxed) { + pending = work.wake.wait(pending).unwrap_or_else(PoisonError::into_inner); + } + if work.stopped.load(Ordering::Relaxed) { + return; + } + pending.pop_front() + }; + let Some(request) = request else { continue }; + if request.cancelled.load(Ordering::Relaxed) { + continue; + } + let result = request.run(); + if !result.cancelled.load(Ordering::Relaxed) { + let mut completed = work.completed.lock().unwrap_or_else(PoisonError::into_inner); + completed.retain(|r| r.comp != result.comp && !r.cancelled.load(Ordering::Relaxed)); + if completed.len() >= MAX_VIEWERS { + completed.pop_front(); + } + completed.push_back(result); + } + request.repaint.request_repaint(); + } + })?; + Ok(Self { shared }) + } + + fn submit(&self, request: Request) { + let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); + pending.retain(|r| r.snapshot.comp != request.snapshot.comp && !r.cancelled.load(Ordering::Relaxed)); + if pending.len() >= MAX_VIEWERS { + pending.pop_front(); + } + pending.push_back(request); + self.shared.wake.notify_one(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Worker { + fn drop(&mut self) { + // Hold the predicate lock when waking, so shutdown cannot race with entering wait. + let mut pending = self.shared.pending.lock().unwrap_or_else(PoisonError::into_inner); + self.shared.stopped.store(true, Ordering::Relaxed); + pending.clear(); + self.shared.wake.notify_one(); + // Do not join an in-flight expression on the UI thread. Entries cancel it on drop. + } +} + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use super::*; + use effectcraft_engine::keyframe::Value; + use effectcraft_engine::project::{Layer, Property}; + use serde_json::json; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc; + + struct Host { + main: std::thread::ThreadId, + on_ui: AtomicUsize, + calls: AtomicUsize, + entered: mpsc::Sender<()>, + gate: (Mutex, std::sync::Condvar), + fail: bool, + } + impl ExprHost for Host { + fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + if std::thread::current().id() == self.main { + self.on_ui.fetch_add(1, Ordering::Relaxed); + return Err("diagnostics evaluated on UI thread".into()); + } + let _ = self.entered.send(()); + let (lock, wake) = &self.gate; + let guard = lock.lock().unwrap(); + let (_guard, timed) = wake.wait_timeout_while(guard, Duration::from_secs(5), |open| !*open).unwrap(); + assert!(!timed.timed_out(), "test did not release the worker"); + if self.fail { Err(format!("failure at {}", ctx.time.0)) } else { Ok(value.clone()) } + } + } + impl Host { + fn release(&self) { + *self.gate.0.lock().unwrap() = true; + self.gate.1.notify_all(); + } + } + fn fixture(fail: bool, blocked: bool) -> (Session, Arc, mpsc::Receiver<()>) { + let (tx, rx) = mpsc::channel(); + let host = Arc::new(Host { + main: std::thread::current().id(), + on_ui: AtomicUsize::new(0), + calls: AtomicUsize::new(0), + entered: tx, + gate: (Mutex::new(!blocked), std::sync::Condvar::new()), + fail, + }); + let mut s = Session { expr: Some(host.clone()), ..Session::default() }; + s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap(); + let hidden = s.execute("layer.newSolid", json!({"name":"Hidden expression"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer":hidden,"path":"transform/opacity","expression":"value"})).unwrap(); + let visible = s.execute("layer.newSolid", json!({"name":"Solo plate"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers":[visible],"switch":"solo","value":true})).unwrap(); + s.execute("layer.select", json!({"layers":[visible]})).unwrap(); + (s, host, rx) + } + fn wait_done(d: &Diagnostics) { + let until = Instant::now() + Duration::from_secs(3); + loop { + if d.worker.as_ref().is_some_and(|w| !w.shared.completed.lock().unwrap().is_empty()) { + return; + } + assert!(Instant::now() < until, "diagnostics did not finish"); + std::thread::sleep(Duration::from_millis(1)); + } + } + fn start(d: &mut Diagnostics, s: &Session, ctx: &egui::Context, now: Instant) { + let cid = s.active_comp_id().unwrap(); + d.update(s, ctx, cid, false, now); + d.update(s, ctx, cid, false, now + QUIET); + } + + #[test] + fn successful_hidden_expressions_run_once_off_the_ui_thread() { + let (s, host, _) = fixture(false, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + wait_done(&d); + for _ in 0..5 { + assert!(d.update(&s, &ctx, cid, false, now + QUIET).is_empty()); + } + assert_eq!(host.calls.load(Ordering::Relaxed), 1); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + } + + #[test] + fn newest_request_replaces_queued_times_and_discards_stale_errors() { + let (mut s, host, entered) = fixture(true, true); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + entered.recv_timeout(Duration::from_secs(3)).unwrap(); + for i in 1..20 { + s.set_time(Tick::from_seconds_f64(i as f64 / 30.)); + start(&mut d, &s, &ctx, now + Duration::from_secs(i)); + assert!(d.entries.back().unwrap().errors.is_empty()); + } + assert_eq!(d.worker.as_ref().unwrap().shared.pending.lock().unwrap().len(), 1); + host.release(); + wait_done(&d); + let errors = d.update(&s, &ctx, cid, false, now + Duration::from_secs(30)); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, format!("failure at {}", s.time().0)); + assert_eq!(host.calls.load(Ordering::Relaxed), 2); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + } + + #[test] + fn warning_preference_and_interaction_prevent_scanning_and_clear_stale_results() { + let (mut s, host, _) = fixture(true, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + for i in 0..10 { + d.update(&s, &ctx, cid, true, now + Duration::from_secs(i)); + } + assert!(d.worker.is_none()); + s.prefs.scripting.error_banner = false; + start(&mut d, &s, &ctx, now + Duration::from_secs(10)); + assert!(d.worker.is_none()); + assert_eq!(host.calls.load(Ordering::Relaxed), 0); + s.prefs.scripting.error_banner = true; + start(&mut d, &s, &ctx, now + Duration::from_secs(11)); + wait_done(&d); + assert_eq!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(12)).len(), 1); + s.prefs.scripting.error_banner = false; + assert!(d.update(&s, &ctx, cid, false, now + Duration::from_secs(13)).is_empty()); + assert!(d.entries.is_empty()); + } + + #[test] + fn replacement_project_with_reused_ids_does_not_reuse_errors() { + let (s, _, _) = fixture(true, false); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + let cid = s.active_comp_id().unwrap(); + start(&mut d, &s, &ctx, now); + wait_done(&d); + assert_eq!(d.update(&s, &ctx, cid, false, now + QUIET).len(), 1); + let (replacement, _, _) = fixture(false, false); + assert_eq!(s.revision, replacement.revision); + assert!(d.update(&replacement, &ctx, cid, false, now + QUIET).is_empty()); + } + + #[test] + fn scans_keep_disabled_syntax_errors_and_non_solo_runtime_errors() { + let (mut s, _, _) = fixture(true, false); + let cid = s.active_comp_id().unwrap(); + let selected = s.state.selected_layers[0]; + s.expr_check = Some(|_| Err("invalid syntax".into())); + s.execute("prop.setExpression", json!({"layer":selected.0,"path":"transform/rotation","expression":"bad("})).unwrap(); + let (mut d, ctx, now) = (Diagnostics::default(), egui::Context::default(), Instant::now()); + start(&mut d, &s, &ctx, now); + wait_done(&d); + let errors = d.update(&s, &ctx, cid, false, now + QUIET); + assert_eq!(errors.len(), 2); + assert!(errors.iter().any(|e| e.disabled && e.message == "invalid syntax")); + assert!(errors.iter().any(|e| !e.disabled && e.layer_name == "Hidden expression")); + } + + #[test] + fn viewer_edits_do_not_wait_for_hidden_diagnostics_with_or_without_warnings() { + use crate::{EffectcraftApp, bench::Driver}; + let (s, host, entered) = fixture(false, true); + let mut d = Driver::new(EffectcraftApp::new(s), egui::vec2(1680., 1020.)); + for _ in 0..3 { + d.step(); + } + let now = Instant::now() + Duration::from_secs(1); + start(&mut d.app.expression_diagnostics, &d.app.session, &d.ctx, now); + entered.recv_timeout(Duration::from_secs(3)).unwrap(); + let layer = d.app.session.state.selected_layers[0]; + d.app.session.state.snapping = false; + let handle = format!("viewer.handle.{}.0", layer.0); + let before = d.app.auto.find(&handle).unwrap().rect; + let comp_rect = d.app.auto.find("viewer.comp").unwrap().rect; + let from = egui::pos2(comp_rect[0] + comp_rect[2] * 0.4, comp_rect[1] + comp_rect[3] * 0.4); + let to = from + egui::vec2(40., 20.); + d.step_with(vec![ + egui::Event::PointerMoved(from), + egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + d.step_with(vec![egui::Event::PointerMoved(to)]); + d.step_with(vec![egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + d.step(); + let after = d.app.auto.find(&handle).unwrap().rect; + assert!(after[0] > before[0] + 30., "selection bounds did not follow the drag: {before:?} -> {after:?}"); + for warnings in [true, false] { + d.app.session.prefs.scripting.error_banner = warnings; + for i in 0..4 { + d.app.session.set_time(Tick::from_seconds_f64(i as f64 / 30.)); + d.app.session.execute("layer.setTransform", json!({"layer":layer.0,"prop":"position","value":[170.+i as f64,90.,0.]})).unwrap(); + d.app.session.execute("layer.setSwitch", json!({"layers":[layer.0],"switch":"video","value":i%2==0})).unwrap(); + d.step(); + } + } + // The diagnostic expression is still blocked on the worker, but every UI frame above + // finished. This assertion also catches reintroducing a second, synchronous banner. + assert!(!*host.gate.0.lock().unwrap()); + assert_eq!(host.on_ui.load(Ordering::Relaxed), 0); + host.release(); + } +} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index ccffeb9f..9a3398d6 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -29,7 +29,7 @@ use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; -use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; +use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, Gpu}; use rayon::prelude::*; @@ -109,6 +109,38 @@ impl std::hash::Hash for FrameKey { } } +/// Where a playing preview is and what it plays next (see [`Frames::set_playhead`]): the RAM +/// preview renders the frames it reaches first before later ones, and keeps them over frames it +/// reaches later or not at all (#595). +#[derive(Clone, Copy, Debug)] +pub struct Playhead { + /// The frame series that plays ([`FrameKey::same_series`]; its `frame` is ignored). + pub series: FrameKey, + /// The frame on screen. + pub cur: i64, + /// The preview's range, skip step and looping (as in its `PreviewPlan`). + pub start: i64, + pub end: i64, + pub step: i64, + pub looping: bool, +} + +impl Playhead { + /// How many frames the preview shows before it reaches `k` (0: on screen); `None` for + /// frames it doesn't reach (other series, outside the range, behind it without looping). + pub fn ahead(&self, k: &FrameKey) -> Option { + if !k.same_series(&self.series) || k.frame < self.start || k.frame > self.end { + return None; + } + let step = self.step.max(1); + if k.frame >= self.cur { + return Some((k.frame - self.cur) / step); + } + // Looping: through the end of the range and around from its start. + self.looping.then(|| (self.end.saturating_sub(self.cur) / step).saturating_add(1).saturating_add((k.frame - self.start) / step)) + } +} + struct Cache { map: HashMap, order: VecDeque, @@ -118,6 +150,8 @@ struct Cache { budget: usize, /// Bytes of GPU frames to keep at most ([`GPU_BUDGET`]). gpu_budget: usize, + /// The playing preview, if any: evictions spare the frames it shows next. + playhead: Option, } impl Cache { @@ -132,12 +166,26 @@ impl Cache { if gpu { self.gpu_bytes += sz; } - while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; - self.remove(&old); - } + self.evict_to_budget(); self.evict_gpu(); } + /// The frame to evict next: the least recently shown, but while a preview plays, the least + /// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU + /// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet) + /// to keep the ones just shown, so a preview longer than the cache played into a gap that + /// prefetch kept refilling while it evicted the next frames (#595). + fn victim(&self) -> Option { + let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) }; + let mut farthest: Option<(i64, usize)> = None; + for (i, k) in self.order.iter().enumerate() { + match p.ahead(k) { + None => return Some(i), + Some(d) if farthest.is_none_or(|(f, _)| d > f) => farthest = Some((d, i)), + Some(_) => {} + } + } + farthest.map(|(_, i)| i) + } /// Video memory: drop the oldest GPU frames past their own budget. fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { @@ -181,7 +229,7 @@ impl Cache { } fn evict_to_budget(&mut self) { while self.bytes > self.budget { - let Some(old) = self.order.pop_front() else { break }; + let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break }; self.remove(&old); } } @@ -213,6 +261,13 @@ type Identity = (u64, Option>); /// Content keys of (project revision, comp), computed once per revision. type ContentKeys = Arc>>; +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct ContentKeyJobs { + running: bool, + pending: VecDeque<(Arc, u64, u64)>, +} + fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { return k; @@ -369,6 +424,8 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + #[cfg(not(target_arch = "wasm32"))] + content_key_jobs: Arc>, gpu_retired: Arc, /// [`comp_content`] identities by (revision, comp), with the project that keeps an identity /// found by [`Frames::identity`] in another project. @@ -388,7 +445,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -403,6 +460,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + #[cfg(not(target_arch = "wasm32"))] + content_key_jobs: Arc::default(), gpu_retired: Arc::default(), identities: Mutex::default(), bases: Mutex::default(), @@ -460,11 +519,27 @@ pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { /// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { - let [w, h] = img.size; let k = fit_factor(img.size, max_side, min_k); if k == 1 { return img; } + average_down(&img, k) +} + +/// A shared frame as texture data (see [`fit_texture_by`]): the frame itself when it needs no +/// averaging, without copying its pixels. The viewer puts every frame a preview shows on screen; +/// copying a 4K frame first held the UI thread for about a millisecond each time (#595). +pub fn fit_shared_texture_by(img: &Arc, max_side: usize, min_k: usize) -> egui::ImageData { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { + return egui::ImageData::Color(img.clone()); + } + average_down(img, k).into() +} + +/// `img` averaged down by `k` (rounded up at the edges). +fn average_down(img: &egui::ColorImage, k: usize) -> egui::ColorImage { + let [w, h] = img.size; let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() @@ -662,7 +737,8 @@ impl Frames { } /// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar. - /// `opts` are the viewer's render options (part of the disk key). + /// `opts` are the viewer's render options (part of the disk key). Native queries return + /// no blue frames until the content key is ready; hashing never blocks timeline painting. pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec { let remote = self.remote.as_ref().filter(|r| r.disk()); let contains = |k: u128| match (&src.disk, remote) { @@ -673,14 +749,64 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } + #[cfg(not(target_arch = "wasm32"))] + let content = { + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); + let Some(content) = known else { + self.request_content_key(src.project.clone(), base.revision, base.comp); + return vec![]; + }; + content + }; + #[cfg(target_arch = "wasm32")] + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (0..frames.clamp(0, 100_000)) .filter(|f| !ram.contains(f)) - .filter(|f| contains(frame_disk_key(&self.content_keys, &src.project, &FrameKey { frame: *f, ..*base }, oh))) + .filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))) .collect() } + #[cfg(not(target_arch = "wasm32"))] + fn request_content_key(&self, project: Arc, revision: u64, comp: u64) { + let mut jobs = self.content_key_jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // Coalesce rapid edits. One worker hashes at most one revision at a time; retained + // snapshots are bounded even with multiple timelines open. + jobs.pending.retain(|(_, _, c)| *c != comp); + if jobs.pending.len() >= 8 { + jobs.pending.pop_front(); + } + jobs.pending.push_back((project, revision, comp)); + if jobs.running { + return; + } + jobs.running = true; + drop(jobs); + let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); + let work = move || loop { + let next = { + let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let next = jobs.pending.pop_back(); + if next.is_none() { + jobs.running = false; + } + next + }; + let Some((project, revision, comp)) = next else { return }; + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { + log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); + } + if let Some(ctx) = &ctx { + ctx.request_repaint(); + } + }; + match &self.pool { + Some(pool) => pool.spawn(work), + None => rayon::spawn(work), + } + } + /// Change the RAM preview cache budget (Settings ▸ Memory & CPU), evicting the oldest /// frames when it shrinks. pub fn set_budget(&self, bytes: usize) { @@ -712,6 +838,26 @@ impl Frames { } } + /// The playing preview's position (`None` when no preview plays): prefetch renders the + /// frames it reaches first before the others, evictions spare the frames it shows next, and + /// queued prefetch frames of its series it no longer reaches are dropped (#595). + pub fn set_playhead(&self, playhead: Option) { + if let Ok(mut c) = self.cache.lock() { + c.playhead = playhead; + } + let Ok(mut q) = self.queue.lock() else { return }; + q.playhead = playhead; + let Some(p) = playhead else { return }; + let Ok(mut inf) = self.inflight.lock() else { return }; + q.jobs.retain(|j| { + let gone = !j.urgent && j.key.same_series(&p.series) && p.ahead(&j.key).is_none(); + if gone { + inf.remove(&j.key); + } + !gone + }); + } + /// Queue a prefetch frame (no-op if cached or already queued/rendering). pub fn request(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts) { self.request_with(src, key, comp, t, opts, false); @@ -756,13 +902,22 @@ impl Frames { for j in q.jobs.iter_mut() { j.urgent = j.key == key; } + // The comp's viewer frames of other times that are rendering are called off too: a + // movie seeking for each of them held the decoder the frame under the pointer + // waited for, so it came seconds after the pointer stopped (#329). Another revision + // of the same frame (a live edit) finishes, as the viewer shows it meanwhile. + for (k, cancel) in &q.urgent_cancels { + if k.comp == key.comp && k.frame != key.frame { + cancel.store(true, Ordering::Relaxed); + } + } } if !inf.insert(key) { return; } q.seq += 1; let seq = q.seq; - q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq }); + q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq, cancel: Arc::default() }); drop(inf); drop(q); #[cfg(not(target_arch = "wasm32"))] @@ -835,7 +990,7 @@ impl Frames { } let job = { let Ok(mut q) = self.queue.lock() else { break }; - let Some(i) = Queue::best(&q.jobs) else { break }; + let Some(i) = q.best() else { break }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { @@ -963,11 +1118,12 @@ impl Worker { let Worker { queue, content_keys, .. } = self; let job = { let Ok(mut q) = queue.lock() else { return false }; - let Some(i) = Queue::best(&q.jobs) else { return false }; + let Some(i) = q.best() else { return false }; let job = q.jobs.swap_remove(i); q.running.insert(job.key); if job.urgent { q.urgent_running += 1; + q.urgent_cancels.push((job.key, job.cancel.clone())); } job }; @@ -977,10 +1133,11 @@ impl Worker { let disk_hit = from_disk.is_some(); let ci = match from_disk { Some(f) => FrameImage::Cpu(Arc::new(egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba))), - None => match self.render_guarded(&job) { + None => match self.render_guarded(&job).filter(|_| !job.cancel.load(Ordering::Relaxed)) { Some(ci) => ci, + // Called off (frames it read may be missing) or failed: not cached, and not + // "in progress" forever: the viewer asks for it again. None => { - // Not "in progress" forever: the viewer asks for it again. self.release(job.key, job.urgent); return true; } @@ -1025,6 +1182,9 @@ impl Worker { q.running.remove(&key); if urgent { q.urgent_running = q.urgent_running.saturating_sub(1); + if let Some(i) = q.urgent_cancels.iter().position(|(k, _)| *k == key) { + q.urgent_cancels.swap_remove(i); + } } } if let Some(ctx) = &self.ctx { @@ -1061,7 +1221,8 @@ impl Worker { return None; } let g = job.src.gpu.as_ref()?; - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + let footage = job.footage(); + let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); r.cache = Some(&job.src.layer_cache); r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator); @@ -1077,7 +1238,8 @@ impl Worker { /// Render a frame; `gpu: false` keeps it off the GPU compositor. fn render_job(&self, job: &Job, gpu: bool) -> FrameImage { - let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts); + let footage = job.footage(); + let mut r = Renderer::new(&job.src.project, &footage, job.opts); r.expr = job.src.expr.as_deref(); let gpu_allowed = gpu && !self.gpu_retired.load(Ordering::Acquire); // A failed GPU attempt may have populated intermediate entries before its scope @@ -1149,6 +1311,15 @@ struct Job { opts: RenderOpts, urgent: bool, seq: u64, + /// Set when the viewer no longer waits for this frame (see [`Frames::request_urgent`]). + cancel: Arc, +} + +impl Job { + /// The footage this job's render reads, called off with the job. + fn footage(&self) -> CancellableFootage<'_> { + CancellableFootage { inner: self.src.footage.as_ref(), cancel: self.cancel.clone() } + } } #[derive(Default)] @@ -1156,13 +1327,28 @@ struct Queue { jobs: Vec, running: HashSet, urgent_running: usize, + /// The running viewer (urgent) frames and how to call them off. + urgent_cancels: Vec<(FrameKey, Arc)>, seq: u64, + /// The playing preview, if any: its prefetch renders nearest first. + playhead: Option, } impl Queue { - /// The job that matters most: urgent first (newest), then prefetch in request order. - fn best(jobs: &[Job]) -> Option { - jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i) + /// The job that matters most: urgent first (newest), then, while a preview plays, the + /// prefetch frame it reaches first, then prefetch in request order. In request order alone, + /// frames asked for again after an eviction (the ones right ahead of the playhead) waited + /// behind every frame further ahead (#595). + fn best(&self) -> Option { + let p = self.playhead.as_ref(); + self.jobs + .iter() + .enumerate() + .max_by_key(|(_, j)| { + let near = if j.urgent { None } else { p.and_then(|p| p.ahead(&j.key)) }; + (j.urgent, near.is_some(), near.map_or(0, |d| -d), if j.urgent { j.seq as i64 } else { -(j.seq as i64) }) + }) + .map(|(i, _)| i) } } @@ -1170,6 +1356,67 @@ impl Queue { mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { + use std::sync::mpsc; + use std::time::{Duration, Instant}; + struct Disk(u128); + impl RemoteFrames for Disk { + fn slots(&self) -> usize { + 1 + } + fn disk(&self) -> bool { + true + } + fn disk_contains(&self, key: u128) -> bool { + self.0 == key + } + fn start(&self, _: RemoteJob, done: RemoteDone) { + done(Err("test has no renderer".into())); + } + } + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", serde_json::json!({"width":32,"height":18,"duration":1})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let opts = RenderOpts::default(); + let stored = disk_cache::frame_key(disk_cache::comp_content_key(&s.project, cid), 2, 1000, opts_hash(&opts)); + let mut frames = Frames { pool: Some(rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap()), ..Default::default() }; + frames.set_remote(Some(Arc::new(Disk(stored)))); + let (entered, started) = mpsc::channel(); + let (release, wait) = mpsc::channel(); + frames.pool.as_ref().unwrap().spawn(move || { + entered.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + }); + started.recv_timeout(Duration::from_secs(3)).unwrap(); + let src = RenderSource { + project: s.project.clone(), + footage: s.footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let mut base = key(1, 0, 0); + base.comp = cid.0; + for revision in 1..50 { + base.revision = revision; + assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty()); + } + assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked"); + assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); + release.send(()).unwrap(); + let until = Instant::now() + Duration::from_secs(3); + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { + assert!(Instant::now() < until); + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready"); + assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed"); + } + /// Frames larger than the texture limit are averaged down by a whole factor (#201: an /// 11000×2200 comp at Full resolution against an 8192 limit). #[test] @@ -1341,6 +1588,94 @@ mod tests { assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread"); } + /// #329: scrubbing a long movie, the viewer frame of every step the pointer passed started + /// rendering and seeked the movie's decoder in turn; the frame under the pointer waited for + /// all of them. A newer viewer frame of another time calls off the one rendering, which is + /// not cached (its footage may be missing). + #[test] + fn a_newer_viewer_frame_calls_off_the_one_rendering() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::render::Image; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + use std::cell::RefCell; + thread_local! { + static CANCEL: RefCell>> = const { RefCell::new(None) }; + } + /// A movie whose first frames take a long seek, which a called-off read gives up. + #[derive(Default)] + struct Seeking { + seeking: AtomicBool, + called_off: AtomicBool, + } + impl FootageSource for Seeking { + fn cancellable(&self, cancel: &Arc, f: &mut dyn FnMut()) { + let prev = CANCEL.replace(Some(cancel.clone())); + f(); + CANCEL.set(prev); + } + fn frame(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + if t < Tick::from_seconds_f64(0.25) { + self.seeking.store(true, Ordering::SeqCst); + for _ in 0..1000 { + if CANCEL.with_borrow(|c| c.as_ref().is_some_and(|c| c.load(Ordering::SeqCst))) { + self.called_off.store(true, Ordering::SeqCst); + return None; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + Some(Arc::new(Image::new(4, 4))) + } + } + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(Seeking::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let content = f.content_of(&s.project, s.revision, cid); + let key = |frame| FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 }; + f.request_urgent(&src, key(0), cid, Tick::ZERO, RenderOpts::default()); + for _ in 0..1000 { + if footage.seeking.load(Ordering::SeqCst) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(footage.seeking.load(Ordering::SeqCst), "the first frame's render reached the footage"); + f.request_urgent(&src, key(150), cid, FrameRate::FPS_30.tick_of(150), RenderOpts::default()); + for _ in 0..1000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(footage.called_off.load(Ordering::SeqCst), "the superseded frame's seek ran on"); + assert!(f.is_cached(&key(150)), "the newest frame rendered"); + assert!(!f.is_cached(&key(0)), "a called-off frame is not cached"); + assert_eq!(f.inflight(), 0, "released"); + } + /// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer /// frames still waiting (they would all render first), but not prefetch frames or another /// comp's viewer frame. @@ -1406,6 +1741,80 @@ mod tests { assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0))); } + fn playhead(cur: i64, looping: bool) -> Playhead { + Playhead { series: key(1, 0, 0), cur, start: 0, end: 99, step: 1, looping } + } + + /// #595: a playing preview's cache is full. The frames just ahead of the playhead were + /// rendered before the ones just shown, so plain LRU evicted them first and the preview + /// played into a gap. Frames the preview no longer reaches go first, then the ones it + /// reaches last. + #[test] + fn a_full_cache_keeps_the_frames_just_ahead_of_the_playhead() { + let f = Frames::default(); + f.set_budget(4 * 16); + // Prefetch rendered 11..=13, then 10 was shown (the most recently used). + for i in [11, 12, 13, 10] { + insert(&f, key(1, i, 0)); + } + let cached = |f: &Frames| f.cached_frames(&key(1, 0, 0)); + f.set_playhead(Some(playhead(10, false))); + // The next frame rendered is the one the preview reaches last: the frames just ahead stay. + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 11, 12, 13]); + // Without looping, a frame behind the playhead goes before any frame ahead of it. + f.set_playhead(Some(playhead(12, false))); + insert(&f, key(1, 14, 0)); + assert_eq!(cached(&f), [10, 12, 13, 14]); + // Looping, the frame just behind is the one reached last. + f.set_playhead(Some(playhead(13, true))); + insert(&f, key(1, 0, 0)); + assert_eq!(cached(&f), [0, 10, 13, 14]); + // Stopped: least recently shown first again. + f.set_playhead(None); + insert(&f, key(1, 50, 0)); + assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0))); + } + + /// #595: prefetch renders the frames the preview reaches first, whatever order they were + /// asked for in (a frame evicted and asked for again waited behind every frame further + /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. + #[test] + fn prefetch_renders_the_frames_the_playhead_reaches_first() { + struct Idle; + impl RemoteFrames for Idle { + fn slots(&self) -> usize { + 0 + } + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + let src = RenderSource { + project: Arc::default(), + footage: Arc::new(effectcraft_engine::render::NoFootage), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + for frame in [40, 30, 20, 5, 12, 11] { + f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default()); + } + f.request(&src, FrameKey { comp: 2, ..key(1, 1, 0) }, ItemId(2), Tick::ZERO, RenderOpts::default()); + f.set_playhead(Some(playhead(10, false))); + let mut order = vec![]; + loop { + let mut q = f.queue.lock().unwrap(); + let Some(i) = q.best() else { break }; + let j = q.jobs.swap_remove(i); + order.push((j.key.comp, j.key.frame)); + } + assert_eq!(order, [(1, 11), (1, 12), (1, 20), (1, 30), (1, 40), (2, 1)]); + assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9..fb381cfd 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -12,7 +12,7 @@ use crate::widgets; use crate::{Dialog, EffectcraftApp}; use effectcraft_color::BlendMode; use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; -use effectcraft_engine::project::{LayerId, LayerSource}; +use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -101,7 +101,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None }; let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; - egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| { + egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { for tool in slot.iter() { let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); @@ -133,13 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() }) .unwrap_or_default(); - let layer_paint = shape_layers - .first() - .and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) - .map(|l| effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()))); - if let Some((fill, stroke, width)) = layer_paint { + let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| { + let lt = l.layer_time(app.session.time()); + (effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt)) + }); + if let Some(((fill, stroke, width), (fill_kind, stroke_kind))) = layer_paint { let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32]; let tool = &mut app.session.state.shape_tool; + tool.fill.kind = fill_kind; + tool.stroke.kind = stroke_kind; if let Some(c) = fill { tool.fill.color = rgb(c); } @@ -214,9 +216,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let tip = tr_args("Appearance Mode: {}", &[&mode]); let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0)); - if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() { + let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip); + if response.clicked() { app.cycle_appearance(ui.ctx()); } + response.context_menu(|ui| { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } + }); app.auto.add("header.appearance", r, &tip); rx = r.min.x - 4.0; rx -= 10.0; @@ -227,7 +255,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mresp = ui.interact(more, egui::Id::new("ws-more"), Sense::click()); p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim }); app.auto.add("header.workspaces", more, "Workspaces"); - mresp.context_menu(|ui| ws_menu(app, ui)); + crate::widgets::context_menu(&mresp, |ui| ws_menu(app, ui)); if mresp.clicked() { widgets::open_popup(ui, egui::Id::new("ws-pop")); } @@ -301,6 +329,24 @@ fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) { } } +/// The Colors property of the first Gradient Fill (`gfill`) or Gradient Stroke (`gstroke`) on the +/// shape `layers`, in contents order through nested groups: (layer, property uid). The toolbar's +/// gradient swatch edits it in the Gradient Editor. +fn gradient_colors(app: &EffectcraftApp, layers: &[u64], paint: &str) -> Option<(u64, u64)> { + fn find(g: &PropGroup, paint: &str, depth: usize) -> Option { + if depth >= 16 { + return None; + } + g.groups().filter(|c| c.enabled).find_map(|c| match c.match_id.as_str() { + m if m == paint => c.get("colors").map(|p| p.uid), + "group" | "contents" => find(c, paint, depth + 1), + _ => None, + }) + } + let comp = app.session.active_comp()?; + layers.iter().find_map(|&l| comp.layer(LayerId(l)).and_then(|layer| layer.props.sub("contents")).and_then(|c| find(c, paint, 0)).map(|u| (l, u))) +} + /// The colour picker of the Fill or Stroke swatch. fn picker_id(key: &str) -> egui::Id { egui::Id::new(("tool-pick", key)) @@ -353,8 +399,9 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa if wresp.clicked() { widgets::open_popup(ui, opts); } - // The swatch picks a Solid Color's colour and opens the options for the other kinds; - // Alt-click steps through the kinds (as in After Effects). + // The swatch picks a Solid Color's colour, edits a Linear or Radial Gradient's colours in + // the Gradient Editor (the selected shape layer's Gradient Fill / Gradient Stroke), and + // opens the options otherwise; Alt-click steps through the kinds (as in After Effects). let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0)); let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label())); paint_swatch(p, sr, &tp, sresp.hovered(), &t); @@ -363,11 +410,19 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa if ui.input(|i| i.modifiers.alt) { let at = PaintKind::ALL.iter().position(|k| *k == tp.kind).unwrap_or(0); let next = PaintKind::ALL.get((at + 1) % PaintKind::ALL.len()).copied().unwrap_or_default(); - let k = format!("{key}Type"); - tool_options(app, json!({k: next.id()})); + paint_kind(app, shape_layers, key, next); } else if tp.kind == PaintKind::Solid { widgets::open_popup(ui, picker_id(key)); + } else if let Some((layer, prop)) = matches!(tp.kind, PaintKind::Linear | PaintKind::Radial) + .then(|| gradient_colors(app, shape_layers, if key == "fill" { "gfill" } else { "gstroke" })) + .flatten() + { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "prop.editGradient", json!({"layer": layer, "prop": prop})) { + app.ui.status = e; + } } else { + // No selected shape layer with that gradient: its options (new shapes' kind). widgets::open_popup(ui, opts); } } @@ -376,7 +431,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa tool_options(app, json!({key: c})); paint(app, key, json!(c)); } - paint_options_popup(app, ui, key, &options, wr.left_bottom()); + paint_options_popup(app, ui, shape_layers, key, &options, wr.left_bottom()); x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; @@ -389,9 +444,28 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa r.max.x + 14.0 } +/// A Fill or Stroke kind: for new shapes, and (as in After Effects) for the selected shape +/// layers' fills or strokes, which None switches off (#732). +fn paint_kind(app: &mut EffectcraftApp, shape_layers: &[u64], key: &str, kind: PaintKind) { + let k_type = format!("{key}Type"); + tool_options(app, json!({&k_type: kind.id()})); + if shape_layers.is_empty() { + return; + } + let mut v = json!({"layers": shape_layers, k_type: kind.id()}); + // A Solid Color takes the swatch's colour. + let color = if key == "fill" { app.session.state.shape_tool.fill.color } else { app.session.state.shape_tool.stroke.color }; + if let (PaintKind::Solid, Some(o)) = (kind, v.as_object_mut()) { + o.insert(key.to_string(), json!(color)); + } + if let Err(e) = app.session.execute("shape.fillStroke", v) { + app.ui.status = e.to_string(); + } +} + /// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the /// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change). -fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, title: &str, pos: egui::Pos2) { +fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) { let id = egui::Id::new(("tool-paint-options", key)); if !widgets::popup_is_open(ui, id) { return; @@ -400,6 +474,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone(); let blend_pop = id.with("blend"); let mut set = None; + let mut kind = None; let mut blend_rect = Rect::NOTHING; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -416,8 +491,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t paint_swatch(&p, r.shrink(3.0), &ToolPaint { kind: k, ..tp.clone() }, resp.hovered(), &t); app.auto.add(&format!("header.{key}Options.{}", k.id()), r, k.label()); if resp.clicked() { - let k_type = format!("{key}Type"); - set = Some(json!({k_type: k.id()})); + kind = Some(k); } } p.text(row(33.0) + vec2(134.0, 0.0), Align2::LEFT_CENTER, tp.kind.label(), Tokens::ui(11.5), t.text_dim); @@ -451,6 +525,9 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t if let Some(v) = set { tool_options(app, v); } + if let Some(k) = kind { + paint_kind(app, shape_layers, key, k); + } } /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4bbac2f7..4e814ea2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -11,7 +11,7 @@ use effectcraft_engine::menus::MenuEntry; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 6] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br"]; +const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -57,6 +57,11 @@ pub(crate) fn brazilian_portuguese(app: &EffectcraftApp) -> bool { language(app) == "pt-br" } +/// The UI is in Czech. +pub(crate) fn czech(app: &EffectcraftApp) -> bool { + language(app) == "cs" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build stays in English because it has no system-locale probe. @@ -72,7 +77,7 @@ fn system_language() -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -82,6 +87,7 @@ fn supported(locale: Option<&str>) -> &'static str { "ja" => "ja", "uk" => "uk", "es" => "es", + "cs" => "cs", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -103,6 +109,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "cs" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Zobrazit v Průzkumníku", + "Reveal in File Manager" => return "Zobrazit ve správci souborů", + _ => {} + } + } match language { "ja" => lookup(JAPANESE, command, source), "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), @@ -110,6 +123,10 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> "uk" => lookup(UKRAINIAN, command, source), "es" => lookup(SPANISH, command, source), "pt-br" => lookup(PORTUGUESE, command, source), + "ru" => lookup(RUSSIAN, command, source), + "fr" => lookup(FRENCH, command, source), + "de" => lookup(GERMAN, command, source), + "cs" => lookup(CZECH, command, source), _ => source, } } @@ -130,6 +147,7 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "uk" => ("Скасувати", "Немає дій для скасування", "Повторити", "Немає дій для повторення"), "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), + "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), _ => return shown, }; match e.command.as_str() { @@ -152,6 +170,17 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { + "cs" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("Přiřadit zkratku pracovnímu prostoru „{name}“"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("Přiřadit zkratku 3D zobrazení „{name}“"); + } + } "uk" => { if source == "Assign Shortcut to Workspace" { if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { @@ -400,6 +429,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "文件..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 图层..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "将帧复制到剪贴板"), ("render.preRender", "Pre-render...", "预渲染..."), ("render.saveCurrentPreview", "Save Current Preview...", "保存当前预览..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本图形中打开"), @@ -682,6 +712,11 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "亮度"), ("", "Stroke Color", "描边颜色"), ("layer.addTextAnimator", "Stroke Width", "描边宽度"), + ("", "Front", "正面"), + ("", "Bevel", "斜面"), + ("", "Side", "侧面"), + ("", "Back", "背面"), + ("layer.addTextAnimator", "Color", "颜色"), ("layer.addTextAnimator", "Tracking", "字符间距"), ("layer.addTextAnimator", "Line Anchor", "行锚点"), ("layer.addTextAnimator", "Line Spacing", "行距"), @@ -1024,6 +1059,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "檔案..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 圖層..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "將影格拷貝到剪貼簿"), ("render.preRender", "Pre-render...", "預先渲染..."), ("render.saveCurrentPreview", "Save Current Preview...", "儲存目前預覽..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本圖形中開啟"), @@ -1306,6 +1342,11 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "亮度"), ("", "Stroke Color", "筆畫色彩"), ("layer.addTextAnimator", "Stroke Width", "筆畫寬度"), + ("", "Front", "正面"), + ("", "Bevel", "斜面"), + ("", "Side", "側面"), + ("", "Back", "背面"), + ("layer.addTextAnimator", "Color", "顏色"), ("layer.addTextAnimator", "Tracking", "字距"), ("layer.addTextAnimator", "Line Anchor", "行錨點"), ("layer.addTextAnimator", "Line Spacing", "行距"), @@ -1645,6 +1686,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "ファイル..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshopレイヤー..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "フレームをクリップボードにコピー"), ("render.preRender", "Pre-render...", "プリレンダリング..."), ("render.saveCurrentPreview", "Save Current Preview...", "現在のプレビューを保存..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "エッセンシャルグラフィックスで開く"), @@ -1927,6 +1969,11 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "明度"), ("", "Stroke Color", "線のカラー"), ("layer.addTextAnimator", "Stroke Width", "線幅"), + ("", "Front", "前面"), + ("", "Bevel", "ベベル"), + ("", "Side", "側面"), + ("", "Back", "背面"), + ("layer.addTextAnimator", "Color", "カラー"), ("layer.addTextAnimator", "Tracking", "トラッキング"), ("layer.addTextAnimator", "Line Anchor", "行のアンカー"), ("layer.addTextAnimator", "Line Spacing", "行間"), @@ -2269,6 +2316,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Файл..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Шари Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Копіювати кадр у буфер обміну"), ("render.preRender", "Pre-render...", "Попередній рендеринг..."), ("render.saveCurrentPreview", "Save Current Preview...", "Зберегти поточний попередній перегляд..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Відкрити в основній графіці"), @@ -2551,6 +2599,11 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "Яскравість"), ("", "Stroke Color", "Колір обведення"), ("layer.addTextAnimator", "Stroke Width", "Товщина обведення"), + ("", "Front", "Передня грань"), + ("", "Bevel", "Фаска"), + ("", "Side", "Бічна грань"), + ("", "Back", "Задня грань"), + ("layer.addTextAnimator", "Color", "Колір"), ("layer.addTextAnimator", "Tracking", "Міжлітерний інтервал"), ("layer.addTextAnimator", "Line Anchor", "Опорна точка рядка"), ("layer.addTextAnimator", "Line Spacing", "Міжрядковий інтервал"), @@ -2894,6 +2947,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Archivo..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Capas de Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Copiar fotograma al portapapeles"), ("render.preRender", "Pre-render...", "Preprocesar..."), ("render.saveCurrentPreview", "Save Current Preview...", "Guardar la previsualización actual..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir en Gráficos esenciales"), @@ -3176,6 +3230,11 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "Brillo"), ("", "Stroke Color", "Color de trazo"), ("layer.addTextAnimator", "Stroke Width", "Ancho de trazo"), + ("", "Front", "Frente"), + ("", "Bevel", "Bisel"), + ("", "Side", "Lado"), + ("", "Back", "Parte posterior"), + ("layer.addTextAnimator", "Color", "Color"), ("layer.addTextAnimator", "Tracking", "Espaciado"), ("layer.addTextAnimator", "Line Anchor", "Anclaje de línea"), ("layer.addTextAnimator", "Line Spacing", "Interlineado"), @@ -3517,6 +3576,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("comp.saveFrameAs", "File...", "Arquivo..."), ("comp.saveFrameAsPsd", "Photoshop Layers...", "Camadas do Photoshop..."), ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Copiar quadro para a área de transferência"), ("render.preRender", "Pre-render...", "Pré-renderização..."), ("render.saveCurrentPreview", "Save Current Preview...", "Salvar pré-visualização atual..."), ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir em Gráficos essenciais"), @@ -3799,6 +3859,11 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.addTextAnimator", "Brightness", "Brilho"), ("", "Stroke Color", "Cor do traçado"), ("layer.addTextAnimator", "Stroke Width", "Largura do traçado"), + ("", "Front", "Frente"), + ("", "Bevel", "Chanfro"), + ("", "Side", "Lateral"), + ("", "Back", "Verso"), + ("layer.addTextAnimator", "Color", "Cor"), ("layer.addTextAnimator", "Tracking", "Tracking"), ("layer.addTextAnimator", "Line Anchor", "Âncora da linha"), ("layer.addTextAnimator", "Line Spacing", "Espaçamento entre linhas"), @@ -3965,6 +4030,2525 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Abrir projeto de demonstração"), ]; +// >>> Russian, French and German menu catalogs + +/// Russian (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const RUSSIAN: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Копировать кадр в буфер обмена"), + ("", "Front", "Передняя грань"), + ("", "Bevel", "Фаска"), + ("", "Side", "Боковая грань"), + ("", "Back", "Задняя грань"), + ("layer.addTextAnimator", "Color", "Цвет"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "О EffectCraft..."), + ("", "Settings...", "Настройки..."), + ("app.settings", "General...", "Общие..."), + ("app.settings", "Startup & Repair...", "Запуск и восстановление..."), + ("app.settings", "Project...", "Проект..."), + ("app.settings", "Composition...", "Композиция..."), + ("app.settings", "Previews...", "Предварительный просмотр..."), + ("app.settings", "Appearance...", "Внешний вид..."), + ("app.settings", "Grids & Guides...", "Сетки и направляющие..."), + ("app.settings", "Labels...", "Метки..."), + ("app.settings", "Type...", "Текст..."), + ("app.settings", "Import...", "Импорт..."), + ("app.settings", "Export...", "Экспорт..."), + ("app.settings", "Audio...", "Звук..."), + ("app.settings", "Disk...", "Диск..."), + ("app.settings", "Memory & CPU...", "Память и CPU..."), + ("app.settings", "Video...", "Видео..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Скрипты и выражения..."), + ("app.hide", "Hide EffectCraft", "Скрыть EffectCraft"), + ("app.hideOthers", "Hide Others", "Скрыть остальные"), + ("app.showAll", "Show All", "Показать все"), + ("app.quit", "Quit EffectCraft", "Выйти из EffectCraft"), + ("", "File", "Файл"), + ("", "New", "Создать"), + ("file.newProject", "New Project", "Новый проект"), + ("file.newFromTemplate", "New Project from Template...", "Новый проект из шаблона..."), + ("project.newFolder", "New Folder", "Новая папка"), + ("file.open", "Open Project...", "Открыть проект..."), + ("", "Open Recent", "Открыть недавние"), + ("file.clearRecent", "Clear Recent Projects", "Очистить список недавних проектов"), + ("file.close", "Close", "Закрыть"), + ("file.closeProject", "Close Project", "Закрыть проект"), + ("file.save", "Save", "Сохранить"), + ("", "Save As", "Сохранить как"), + ("file.saveAs", "Save As...", "Сохранить как..."), + ("file.saveCopy", "Save a Copy...", "Сохранить копию..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Сохранить копию как XML..."), + ("templates.saveAs", "Save as Template...", "Сохранить как шаблон..."), + ("file.incrementAndSave", "Increment and Save", "Увеличить номер версии и сохранить"), + ("file.revert", "Revert", "Вернуть к сохраненному"), + ("", "Import", "Импорт"), + ("file.import", "File...", "Файл..."), + ("file.importMultiple", "Multiple Files...", "Несколько файлов..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Заполнитель..."), + ("file.importSolid", "Solid...", "Сплошной цвет..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Точка схода (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Шаблон основной графики..."), + ("", "Import Recent Footage", "Импортировать недавние материалы"), + ("file.clearRecentFootage", "Clear Recent Footage", "Очистить список недавних материалов"), + ("", "Export", "Экспорт"), + ("renderQueue.add", "Add to Render Queue", "Добавить в очередь визуализации"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Шаблон основной графики..."), + ("app.find", "Find", "Найти"), + ("layer.addItem", "Add Footage to Comp", "Добавить материал в композицию"), + ("file.newCompFromSelection", "New Comp from Selection...", "Новая композиция из выделенного..."), + ("", "Dependencies", "Зависимости"), + ("file.collectFiles", "Collect Files...", "Собрать файлы..."), + ("file.consolidateFootage", "Consolidate All Footage", "Объединить повторяющиеся материалы"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Удалить неиспользуемые материалы"), + ("file.reduceProject", "Reduce Project", "Сократить проект"), + ("file.findMissing", "Find Missing Effects", "Найти отсутствующие эффекты"), + ("file.findMissing", "Find Missing Fonts", "Найти отсутствующие шрифты"), + ("file.findMissing", "Find Missing Footage", "Найти отсутствующие материалы"), + ("file.relinkFootage", "Relink Missing Footage...", "Пересвязать отсутствующие материалы..."), + ("file.watchFolder", "Watch Folder...", "Папка наблюдения..."), + ("", "Scripts", "Скрипты"), + ("file.installScript", "Install Script File...", "Установить файл скрипта..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."), + ("file.runScript", "Run Script File...", "Выполнить файл скрипта..."), + ("", "Create Proxy", "Создать суррогат"), + ("file.createProxy", "Still...", "Стоп-кадр..."), + ("file.createProxy", "Movie...", "Видео..."), + ("", "Set Proxy", "Назначить суррогат"), + ("file.setProxy", "File...", "Файл..."), + ("file.setProxyNone", "None", "Нет"), + ("", "Interpret Footage", "Интерпретировать материал"), + ("file.interpretFootage", "Main...", "Основные..."), + ("file.interpretProxy", "Proxy...", "Суррогат..."), + ("file.rememberInterpretation", "Remember Interpretation", "Запомнить интерпретацию"), + ("file.applyInterpretation", "Apply Interpretation", "Применить интерпретацию"), + ("", "Replace Footage", "Заменить материал"), + ("file.replaceFootage", "File...", "Файл..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Заполнитель..."), + ("file.replaceWithSolid", "Solid...", "Сплошной цвет..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Многослойной композицией"), + ("file.reloadFootage", "Reload Footage", "Перезагрузить материал"), + ("file.revealInFinder", "Reveal in Finder", "Показать в Finder"), + ("file.projectSettings", "Project Settings...", "Параметры проекта..."), + ("app.quit", "Exit", "Выход"), + ("", "Edit", "Правка"), + ("edit.undo", "Undo", "Отменить"), + ("edit.redo", "Redo", "Повторить"), + ("", "History", "История"), + ("app.commandPalette", "Quick Apply...", "Быстрое применение..."), + ("edit.cut", "Cut", "Вырезать"), + ("edit.copy", "Copy", "Копировать"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Копировать со связями свойств"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Копировать с относительными связями свойств"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Копировать только выражение"), + ("edit.paste", "Paste", "Вставить"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Вставить обращенные ключевые кадры"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Вставить текст с совпадающим форматированием"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Вставить только форматирование текста"), + ("edit.clear", "Clear", "Очистить"), + ("edit.duplicate", "Duplicate", "Дублировать"), + ("edit.splitLayer", "Split Layer", "Разделить слой"), + ("edit.liftWorkArea", "Lift Work Area", "Извлечь рабочую область"), + ("edit.extractWorkArea", "Extract Work Area", "Вырезать рабочую область"), + ("edit.selectAll", "Select All", "Выделить все"), + ("edit.deselectAll", "Deselect All", "Снять выделение со всего"), + ("", "Label", "Метка"), + ("edit.selectLabelGroup", "Select Label Group", "Выделить группу метки"), + ("edit.label", "None", "Нет"), + ("edit.label", "Red", "Красный"), + ("edit.label", "Yellow", "Желтый"), + ("edit.label", "Aqua", "Аква"), + ("edit.label", "Pink", "Розовый"), + ("edit.label", "Lavender", "Лавандовый"), + ("edit.label", "Peach", "Персиковый"), + ("edit.label", "Sea Foam", "Морская пена"), + ("edit.label", "Blue", "Синий"), + ("edit.label", "Green", "Зеленый"), + ("edit.label", "Purple", "Фиолетовый"), + ("edit.label", "Orange", "Оранжевый"), + ("edit.label", "Brown", "Коричневый"), + ("edit.label", "Fuchsia", "Фуксия"), + ("edit.label", "Cyan", "Голубой"), + ("edit.label", "Sandstone", "Песчаный"), + ("edit.label", "Dark Green", "Темно-зеленый"), + ("app.settings", "Edit Label Colors...", "Изменить цвета меток..."), + ("", "Select Keyframe Label Group", "Выделить группу метки ключевых кадров"), + ("keys.selectLabelGroup", "On Selected Layers", "На выделенных слоях"), + ("keys.selectLabelGroup", "On All Layers", "На всех слоях"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Видимые ключевые кадры на выделенных слоях"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Видимые ключевые кадры на всех слоях"), + ("", "Purge", "Очистить память"), + ("edit.purge", "All Cache...", "Весь кеш..."), + ("edit.purge", "All Memory & Disk Cache...", "Всю память и дисковый кеш..."), + ("edit.purge", "All Memory", "Всю память"), + ("edit.purge", "All Disk Cache...", "Весь дисковый кеш..."), + ("edit.purge", "All 3D Cache...", "Весь 3D-кеш..."), + ("edit.purgeUndo", "Undo", "Историю отмен"), + ("edit.purge", "Image Cache Memory", "Кеш изображений в памяти"), + ("edit.purge", "Snapshot", "Снимок"), + ("edit.editOriginal", "Edit Original...", "Редактировать оригинал..."), + ("", "Templates", "Шаблоны"), + ("app.templates", "Render Settings...", "Параметры визуализации..."), + ("app.templates", "Output Module...", "Модуль вывода..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Сочетания клавиш"), + ("", "Preferences", "Настройки"), + ("", "Composition", "Композиция"), + ("comp.new", "New Composition...", "Новая композиция..."), + ("comp.settings", "Composition Settings...", "Параметры композиции..."), + ("comp.setPosterTime", "Set Poster Time", "Задать время кадра-афиши"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Обрезать композицию по рабочей области"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Обрезать композицию по области интереса"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Обрезать композицию по границам выделенных слоев"), + ("render.addOutputModule", "Add Output Module", "Добавить модуль вывода"), + ("", "Preview", "Предварительный просмотр"), + ("playback.toggle", "Play Current Preview", "Воспроизвести текущий предпросмотр"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Кэшировать кадры в простое"), + ("playback.audio", "Audio", "Звук"), + ("", "Save Frame As", "Сохранить кадр как"), + ("comp.saveFrameAs", "File...", "Файл..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Слои Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Предварительный рендеринг..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Сохранить текущий предпросмотр..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Открыть в основной графике"), + ("", "Responsive Design — Time", "Адаптивный дизайн — время"), + ("comp.responsiveTime", "Create Intro", "Создать вступление"), + ("comp.responsiveTime", "Create Outro", "Создать завершение"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Создать защищенный участок из рабочей области"), + ("comp.flowchart", "Composition Flowchart", "Блок-схема композиции"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Мини-блок-схема композиции"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Создать среду VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Извлечь кубическую карту..."), + ("", "Layer", "Слой"), + ("layer.newText", "Text", "Текст"), + ("layer.newSolid", "Solid...", "Сплошной цвет..."), + ("layer.newLight", "Light...", "Свет..."), + ("layer.newCamera", "Camera...", "Камера..."), + ("layer.newNull", "Null Object", "Нулевой объект"), + ("layer.newShape", "Shape Layer", "Слой-фигура"), + ("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."), + ("layer.new3dPrimitive", "Cube", "Куб"), + ("layer.new3dPrimitive", "Sphere", "Сфера"), + ("layer.new3dPrimitive", "Plane", "Плоскость"), + ("layer.new3dPrimitive", "Torus", "Тор"), + ("layer.new3dPrimitive", "Cone", "Конус"), + ("layer.new3dPrimitive", "Cylinder", "Цилиндр"), + ("layer.settings", "Layer Settings...", "Параметры слоя..."), + ("layer.openLayer", "Open Layer", "Открыть слой"), + ("layer.openSource", "Open Layer Source", "Открыть источник слоя"), + ("layer.revealInFinder", "Reveal in Finder", "Показать в Finder"), + ("", "Mask", "Маска"), + ("layer.addMask", "New Mask", "Новая маска"), + ("layer.mask.shape", "Mask Shape...", "Форма маски..."), + ("layer.mask.set", "Mask Feather...", "Растушевка маски..."), + ("layer.mask.set", "Mask Opacity...", "Непрозрачность маски..."), + ("layer.mask.set", "Mask Expansion...", "Расширение маски..."), + ("layer.mask.reset", "Reset Mask", "Сбросить маску"), + ("layer.mask.remove", "Remove Mask", "Удалить маску"), + ("layer.mask.removeAll", "Remove All Masks", "Удалить все маски"), + ("track.mask", "Track Mask", "Отслеживать маску"), + ("", "Mode", "Режим"), + ("layer.mask.mode", "None", "Нет"), + ("layer.mask.mode", "Add", "Добавление"), + ("layer.mask.mode", "Subtract", "Вычитание"), + ("layer.mask.mode", "Intersect", "Пересечение"), + ("layer.mask.mode", "Lighten", "Осветление"), + ("layer.mask.mode", "Darken", "Затемнение"), + ("layer.mask.mode", "Difference", "Разница"), + ("layer.mask.invert", "Inverted", "Инвертированная"), + ("layer.mask.lock", "Locked", "Заблокирована"), + ("", "Motion Blur", "Размытие в движении"), + ("layer.mask.motionBlur", "Same As Layer", "Как у слоя"), + ("layer.mask.motionBlur", "On", "Включено"), + ("layer.mask.motionBlur", "Off", "Выключено"), + ("", "Feather Falloff", "Спад растушевки"), + ("layer.mask.featherFalloff", "Smooth", "Плавный"), + ("layer.mask.featherFalloff", "Linear", "Линейный"), + ("layer.mask.unlockAll", "Unlock All Masks", "Разблокировать все маски"), + ("layer.mask.lockOthers", "Lock Other Masks", "Заблокировать остальные маски"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Скрыть заблокированные маски"), + ("", "Mask and Shape Path", "Контур маски и фигуры"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Замкнутый"), + ("path.convertToBezier", "Convert To Bezier Path", "Преобразовать в кривую Безье"), + ("path.setFirstVertex", "Set First Vertex", "Задать первую вершину"), + ("path.freeTransform", "Free Transform Points", "Свободное трансформирование точек"), + ("", "Quality", "Качество"), + ("layer.quality", "Best", "Наилучшее"), + ("layer.quality", "Draft", "Черновое"), + ("layer.quality", "Wireframe", "Каркас"), + ("layer.sampling", "Bilinear", "Билинейная"), + ("layer.sampling", "Bicubic", "Бикубическая"), + ("", "Switches", "Переключатели"), + ("layer.hideOtherVideo", "Hide Other Video", "Скрыть остальное видео"), + ("layer.showAllVideo", "Show All Video", "Показать все видео"), + ("layer.unlockAll", "Unlock All Layers", "Разблокировать все слои"), + ("layer.expressions", "Enable Expressions", "Включить выражения"), + ("layer.expressions", "Disable Expressions", "Отключить выражения"), + ("layer.setSwitch", "Shy", "Застенчивый"), + ("layer.setSwitch", "Lock", "Заблокировать"), + ("layer.setSwitch", "Audio", "Звук"), + ("layer.setSwitch", "Video", "Видео"), + ("layer.setSwitch", "Solo", "Соло"), + ("layer.setSwitch", "Effect", "Эффект"), + ("layer.setSwitch", "Collapse", "Свернуть трансформации"), + ("layer.setSwitch", "Motion Blur", "Размытие в движении"), + ("layer.setSwitch", "Adjustment Layer", "Корректирующий слой"), + ("", "Transform", "Трансформирование"), + ("layer.transform", "Reset", "Сбросить"), + ("layer.setTransform", "Anchor Point...", "Опорная точка..."), + ("layer.setTransform", "Position...", "Положение..."), + ("layer.setTransform", "Scale...", "Масштаб..."), + ("layer.setTransform", "Orientation...", "Ориентация..."), + ("layer.setTransform", "Rotation...", "Вращение..."), + ("layer.setTransform", "Opacity...", "Непрозрачность..."), + ("layer.transform", "Flip Horizontal", "Отразить по горизонтали"), + ("layer.transform", "Flip Vertical", "Отразить по вертикали"), + ("layer.transform", "Center In View", "Центрировать в окне просмотра"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Центрировать опорную точку в содержимом слоя"), + ("layer.transform", "Fit to Comp", "По размеру композиции"), + ("layer.transform", "Fit to Comp Width", "По ширине композиции"), + ("layer.transform", "Fit to Comp Height", "По высоте композиции"), + ("layer.autoOrient", "Auto-Orient...", "Автоориентация..."), + ("", "Time", "Время"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Включить переназначение времени"), + ("layer.timeReverse", "Time-Reverse Layer", "Обратить время слоя"), + ("layer.timeStretch", "Time Stretch...", "Растяжение времени..."), + ("layer.freezeFrame", "Freeze Frame", "Стоп-кадр"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Заморозить на последнем кадре"), + ("layer.alignVideoToData", "Align Video to Data", "Выровнять видео по данным"), + ("", "Frame Blending", "Смешивание кадров"), + ("layer.frameBlending", "Off", "Выключено"), + ("layer.frameBlending", "Frame Mix", "Смешивание кадров"), + ("layer.frameBlending", "Pixel Motion", "Движение пикселей"), + ("layer.setSwitch", "3D Layer", "3D-слой"), + ("layer.setSwitch", "Guide Layer", "Направляющий слой"), + ("layer.environment", "Environment Layer", "Слой среды"), + ("", "Markers", "Маркеры"), + ("layer.addMarker", "Add Marker", "Добавить маркер"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Обновить маркеры из источника"), + ("layer.markersLock", "Lock Markers", "Заблокировать маркеры"), + ("layer.deleteAllMarkers", "Delete All Markers", "Удалить все маркеры"), + ("layer.setSwitch", "Preserve Transparency", "Сохранять прозрачность"), + ("", "Blending Mode", "Режим наложения"), + ("layer.setBlendMode", "Normal", "Обычный"), + ("layer.setBlendMode", "Dissolve", "Растворение"), + ("layer.setBlendMode", "Dancing Dissolve", "Танцующее растворение"), + ("layer.setBlendMode", "Darken", "Затемнение"), + ("layer.setBlendMode", "Multiply", "Умножение"), + ("layer.setBlendMode", "Color Burn", "Затемнение основы"), + ("layer.setBlendMode", "Classic Color Burn", "Классическое затемнение основы"), + ("layer.setBlendMode", "Linear Burn", "Линейное затемнение"), + ("layer.setBlendMode", "Darker Color", "Темнее"), + ("layer.setBlendMode", "Add", "Добавление"), + ("layer.setBlendMode", "Lighten", "Осветление"), + ("layer.setBlendMode", "Screen", "Экран"), + ("layer.setBlendMode", "Color Dodge", "Осветление основы"), + ("layer.setBlendMode", "Classic Color Dodge", "Классическое осветление основы"), + ("layer.setBlendMode", "Linear Dodge", "Линейное осветление"), + ("layer.setBlendMode", "Lighter Color", "Светлее"), + ("layer.setBlendMode", "Overlay", "Перекрытие"), + ("layer.setBlendMode", "Soft Light", "Мягкий свет"), + ("layer.setBlendMode", "Hard Light", "Жесткий свет"), + ("layer.setBlendMode", "Linear Light", "Линейный свет"), + ("layer.setBlendMode", "Vivid Light", "Яркий свет"), + ("layer.setBlendMode", "Pin Light", "Точечный свет"), + ("layer.setBlendMode", "Hard Mix", "Жесткое смешение"), + ("layer.setBlendMode", "Difference", "Разница"), + ("layer.setBlendMode", "Classic Difference", "Классическая разница"), + ("layer.setBlendMode", "Exclusion", "Исключение"), + ("layer.setBlendMode", "Subtract", "Вычитание"), + ("layer.setBlendMode", "Divide", "Деление"), + ("layer.setBlendMode", "Hue", "Цветовой тон"), + ("layer.setBlendMode", "Saturation", "Насыщенность"), + ("layer.setBlendMode", "Color", "Цветность"), + ("layer.setBlendMode", "Luminosity", "Яркость"), + ("layer.setBlendMode", "Stencil Alpha", "Трафарет альфа-канала"), + ("layer.setBlendMode", "Stencil Luma", "Трафарет яркости"), + ("layer.setBlendMode", "Silhouette Alpha", "Силуэт альфа-канала"), + ("layer.setBlendMode", "Silhouette Luma", "Силуэт яркости"), + ("layer.setBlendMode", "Alpha Add", "Сложение альфа-канала"), + ("layer.setBlendMode", "Luminescent Premul", "Люминесцентное предумножение"), + ("layer.setBlendMode", "Next Blending Mode", "Следующий режим наложения"), + ("layer.setBlendMode", "Previous Blending Mode", "Предыдущий режим наложения"), + ("", "Track Matte", "Трек-маска"), + ("layer.trackMatte", "No Track Matte", "Без трек-маски"), + ("layer.trackMatte", "Alpha Matte", "Альфа-маска"), + ("layer.trackMatte", "Alpha Inverted Matte", "Инвертированная альфа-маска"), + ("layer.trackMatte", "Luma Matte", "Маска яркости"), + ("layer.trackMatte", "Luma Inverted Matte", "Инвертированная маска яркости"), + ("layer.trackMatte", "Matte with Layer Above", "Маска от слоя выше"), + ("layer.trackMatte", "Matte with Layer Below", "Маска от слоя ниже"), + ("", "Layer Styles", "Стили слоев"), + ("layer.style.options", "Layer Style Options...", "Параметры стилей слоев..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Преобразовать в редактируемые стили"), + ("layer.style.showAll", "Show All", "Показать все"), + ("layer.style.removeAll", "Remove All", "Удалить все"), + ("layer.style.dropShadow", "Drop Shadow", "Тень"), + ("layer.style.innerShadow", "Inner Shadow", "Внутренняя тень"), + ("layer.style.outerGlow", "Outer Glow", "Внешнее свечение"), + ("layer.style.innerGlow", "Inner Glow", "Внутреннее свечение"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Фаска и тиснение"), + ("layer.style.satin", "Satin", "Атлас"), + ("layer.style.colorOverlay", "Color Overlay", "Наложение цвета"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Наложение градиента"), + ("layer.style.stroke", "Stroke", "Обводка"), + ("path.groupShapes", "Group Shapes", "Сгруппировать фигуры"), + ("path.ungroupShapes", "Ungroup Shapes", "Разгруппировать фигуры"), + ("", "Arrange", "Упорядочить"), + ("layer.arrange", "Bring Layer to Front", "Переместить слой на передний план"), + ("layer.arrange", "Bring Layer Forward", "Переместить слой вперед"), + ("layer.arrange", "Send Layer Backward", "Переместить слой назад"), + ("layer.arrange", "Send Layer to Back", "Переместить слой на задний план"), + ("", "Reveal", "Показать"), + ("layer.revealSource", "Reveal Layer Source in Project", "Показать источник слоя в проекте"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Показать слой в блок-схеме проекта"), + ("comp.revealInProject", "Reveal Composition in Project", "Показать композицию в проекте"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Показать ошибки выражений"), + ("", "Create", "Создать"), + ("layer.create", "Convert to Editable Text", "Преобразовать в редактируемый текст"), + ("layer.create", "Create Shapes from Text", "Создать фигуры из текста"), + ("layer.create", "Create Masks from Text", "Создать маски из текста"), + ("layer.create", "Create Shapes from Vector Layer", "Создать фигуры из векторного слоя"), + ("layer.create", "Create Keyframes from Data", "Создать ключевые кадры из данных"), + ("layer.create", "Null Controllers for Positional Points", "Нулевые контроллеры для точек положения"), + ("layer.create", "Null Controllers for Path Points", "Нулевые контроллеры для точек контура"), + ("layer.create", "Nulls Following Path Points", "Нулевые объекты, следующие за точками контура"), + ("layer.create", "Null Tracing Along Path", "Нулевой объект, движущийся вдоль контура"), + ("layer.create", "Create 3D Layer Instance", "Создать экземпляр 3D-слоя"), + ("", "Camera", "Камера"), + ("camera.fromView", "Create Camera from 3D View", "Создать камеру из 3D-вида"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Создать стерео-риг 3D"), + ("camera.orbitNull", "Create Orbit Null", "Создать орбитальный нулевой объект"), + ("camera.fromModel", "Create Cameras from 3D Model", "Создать камеры из 3D-модели"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Связать фокусное расстояние с точкой интереса"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Связать фокусное расстояние со слоем"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Задать фокусное расстояние по слою"), + ("layer.cameraSettings", "Camera Settings...", "Параметры камеры..."), + ("view.reset3DView", "Reset 3D View", "Сбросить 3D-вид"), + ("", "Light", "Свет"), + ("light.fromModel", "Create Lights from 3D Model", "Создать источники света из 3D-модели"), + ("light.controlWithCamera", "Control Light with Camera", "Управлять светом с помощью камеры"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Создать фоновый слой освещения среды"), + ("", "Material", "Материал"), + ("material.revealSource", "Reveal Material Source in Project", "Показать источник материала в проекте"), + ("material.reset", "Reset Material", "Сбросить материал"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Дублировать и назначить материал"), + ("layer.autoTrace", "Auto-trace...", "Автотрассировка..."), + ("layer.precompose", "Pre-compose...", "Преокомпоновать..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Определение монтажных склеек..."), + ("", "Effect", "Эффект"), + ("window.panel", "Effect Controls", "Управление эффектами"), + ("effect.applyLast", "Last Effect", "Последний эффект"), + ("effect.removeAll", "Remove All", "Удалить все"), + ("effect.manage", "Manage Effects...", "Управление эффектами..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Загрузить плагин эффектов..."), + ("", "Animation", "Анимация"), + ("anim.savePreset", "Save Animation Preset...", "Сохранить заготовку анимации..."), + ("anim.applyPreset", "Apply Animation Preset...", "Применить заготовку анимации..."), + ("", "Recent Animation Presets", "Недавние заготовки анимации"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Очистить список недавних заготовок"), + ("anim.browsePresets", "Browse Presets...", "Просмотреть заготовки..."), + ("", "Text Animation Presets", "Текстовые заготовки анимации"), + ("layer.applyTextPreset", "Typewriter", "Печатная машинка"), + ("layer.applyTextPreset", "Fade Up Characters", "Появление символов"), + ("layer.applyTextPreset", "Bounce In Words", "Появление слов с отскоком"), + ("layer.applyTextPreset", "Tracking In", "Трекинг внутрь"), + ("layer.applyTextPreset", "Scramble", "Перемешивание"), + ("layer.applyTextPreset", "Blur In", "Появление из размытия"), + ("layer.applyTextPreset", "Jitter", "Дрожание"), + ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), + ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), + ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), + ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Преобразовать звук в ключевые кадры"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Преобразовать выражение в ключевые кадры"), + ("keys.easyEase", "Easy Ease", "Плавное смягчение"), + ("keys.easyEaseIn", "Easy Ease In", "Плавное замедление"), + ("keys.easyEaseOut", "Easy Ease Out", "Плавное ускорение"), + ("keys.exponentialScale", "Exponential Scale", "Экспоненциальный масштаб"), + ("keys.rpfCameraImport", "RPF Camera Import", "Импорт камеры RPF"), + ("layer.sequence", "Sequence Layers...", "Разложить слои последовательно..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Обратить время ключевых кадров"), + ("", "Animate Text", "Анимировать текст"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Включить 3D для каждого символа"), + ("layer.addTextAnimator", "Anchor Point", "Опорная точка"), + ("layer.addTextAnimator", "Position", "Положение"), + ("layer.addTextAnimator", "Scale", "Масштаб"), + ("layer.addTextAnimator", "Skew", "Наклон"), + ("layer.addTextAnimator", "Rotation", "Вращение"), + ("layer.addTextAnimator", "Opacity", "Непрозрачность"), + ("layer.addTextAnimator", "All Transform Properties", "Все свойства трансформирования"), + ("", "Fill Color", "Цвет заливки"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Цветовой тон"), + ("layer.addTextAnimator", "Saturation", "Насыщенность"), + ("layer.addTextAnimator", "Brightness", "Яркость"), + ("", "Stroke Color", "Цвет обводки"), + ("layer.addTextAnimator", "Stroke Width", "Толщина обводки"), + ("layer.addTextAnimator", "Tracking", "Трекинг"), + ("layer.addTextAnimator", "Line Anchor", "Опорная точка строки"), + ("layer.addTextAnimator", "Line Spacing", "Межстрочный интервал"), + ("layer.addTextAnimator", "Character Offset", "Смещение символов"), + ("layer.addTextAnimator", "Character Value", "Код символа"), + ("layer.addTextAnimator", "Blur", "Размытие"), + ("text.animatorFontAxes", "Variable Font Axes", "Оси вариативного шрифта"), + ("", "Add Text Selector", "Добавить селектор текста"), + ("text.addSelector", "Range", "Диапазон"), + ("text.addSelector", "Wiggly", "Дрожащий"), + ("text.addSelector", "Expression", "Выражение"), + ("text.removeAllAnimators", "Remove All Text Animators", "Удалить все аниматоры текста"), + ("prop.setExpression", "Add Expression", "Добавить выражение"), + ("essential.addProperty", "Add Property to Essential Graphics", "Добавить свойство в основную графику"), + ("prop.separateDimensions", "Separate Dimensions", "Разделить измерения"), + ("track.camera", "Track Camera", "Отслеживать камеру"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Стабилизатор искажений VFX"), + ("track.motion", "Track Motion", "Отслеживать движение"), + ("track.stabilize", "Stabilize Motion", "Стабилизировать движение"), + ("track.property", "Track this Property", "Отслеживать это свойство"), + ("anim.reveal", "Reveal Properties with Keyframes", "Показать свойства с ключевыми кадрами"), + ("anim.reveal", "Reveal Properties with Animation", "Показать анимированные свойства"), + ("anim.reveal", "Reveal All Modified Properties", "Показать все измененные свойства"), + ("", "View", "Просмотр"), + ("view.newViewer", "New Viewer", "Новое окно просмотра"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Разделить с новым закрепленным окном просмотра"), + ("view.zoomIn", "Zoom In", "Увеличить"), + ("view.zoomOut", "Zoom Out", "Уменьшить"), + ("", "Resolution", "Разрешение"), + ("view.res.full", "Full", "Полное"), + ("view.res.half", "Half", "Половина"), + ("view.res.third", "Third", "Треть"), + ("view.res.quarter", "Quarter", "Четверть"), + ("view.res.custom", "Custom...", "Настраиваемое..."), + ("view.displayColorManagement", "Use Display Color Management", "Использовать управление цветом дисплея"), + ("", "Simulate Output", "Имитация вывода"), + ("view.simulateOutput", "No Output Simulation", "Без имитации вывода"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Устаревший Macintosh RGB (гамма 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Стандартный интернет-RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Линейный (гамма 1.0)"), + ("view.customRgb", "My Custom RGB...", "Свой RGB..."), + ("view.simulateOutput", "Custom...", "Настраиваемый..."), + ("view.rulers", "Show Rulers", "Показать линейки"), + ("", "Panel Background Color", "Цвет фона панели"), + ("view.panelBackground", "Black", "Черный"), + ("view.panelBackground", "Dark Gray", "Темно-серый"), + ("view.panelBackground", "Medium Gray (Default)", "Средне-серый (по умолчанию)"), + ("view.panelBackground", "Light Gray", "Светло-серый"), + ("view.panelBackground", "White", "Белый"), + ("view.panelBackground", "Custom", "Свой"), + ("view.panelBackground", "Select Custom Background Color...", "Выбрать цвет фона..."), + ("view.guides", "Show Guides", "Показать направляющие"), + ("view.snapToGuides", "Snap to Guides", "Привязывать к направляющим"), + ("view.lockGuides", "Lock Guides", "Закрепить направляющие"), + ("view.addGuide", "Add Guide...", "Добавить направляющую..."), + ("view.clearGuides", "Clear Guides", "Очистить направляющие"), + ("view.importGuides", "Import Guides...", "Импортировать направляющие..."), + ("view.exportGuides", "Export Guides...", "Экспортировать направляющие..."), + ("view.grid", "Show Grid", "Показать сетку"), + ("view.snapToGrid", "Snap to Grid", "Привязывать к сетке"), + ("view.snapping", "Snapping", "Привязывание"), + ("view.options", "View Options...", "Параметры просмотра..."), + ("view.layerControls", "Show Layer Controls", "Показать элементы управления слоя"), + ("", "Switch View Layout", "Сменить компоновку видов"), + ("view.layout", "1 View", "1 вид"), + ("view.layout", "2 Views", "2 вида"), + ("view.layout", "4 Views", "4 вида"), + ("view.shareViewOptions", "Share View Options", "Общие параметры просмотра"), + ("", "Switch 3D View", "Сменить 3D-вид"), + ("view.3d.activeCamera", "Active Camera", "Активная камера"), + ("view.3d.default", "Default", "По умолчанию"), + ("view.3d.front", "Front", "Спереди"), + ("view.3d.left", "Left", "Слева"), + ("view.3d.top", "Top", "Сверху"), + ("view.3d.back", "Back", "Сзади"), + ("view.3d.right", "Right", "Справа"), + ("view.3d.bottom", "Bottom", "Снизу"), + ("view.3d.custom1", "Custom View 1", "Пользовательский вид 1"), + ("view.3d.custom2", "Custom View 2", "Пользовательский вид 2"), + ("view.3d.custom3", "Custom View 3", "Пользовательский вид 3"), + ("", "Assign Shortcut to 3D View", "Назначить сочетание клавиш 3D-виду"), + ("view.3d.last", "Switch to Last 3D View", "Вернуться к последнему 3D-виду"), + ("view.lookAtSelected", "Look at Selected Layers", "Навести на выделенные слои"), + ("view.lookAtAll", "Look at All Layers", "Навести на все слои"), + ("time.set", "Go to Time...", "Перейти ко времени..."), + ("view.fullScreen", "Enter Full Screen", "Перейти в полноэкранный режим"), + ("", "Window", "Окно"), + ("", "Workspace", "Рабочая среда"), + ("window.workspace", "Default", "По умолчанию"), + ("window.workspace", "Review", "Рецензирование"), + ("window.workspace", "Learn", "Обучение"), + ("window.workspace", "Small Screen", "Малый экран"), + ("window.workspace", "Standard", "Стандартная"), + ("window.workspace", "All Panels", "Все панели"), + ("window.workspace", "Animation", "Анимация"), + ("window.workspace", "Color", "Цвет"), + ("window.workspace", "Effects", "Эффекты"), + ("window.workspace", "Essential Graphics", "Основная графика"), + ("window.workspace", "Minimal", "Минимальная"), + ("window.workspace", "Motion Tracking", "Отслеживание движения"), + ("window.workspace", "Paint", "Рисование"), + ("window.workspace", "Text", "Текст"), + ("window.workspace", "Undocked Panels", "Отсоединенные панели"), + ("window.resetWorkspace", "Reset to Saved Layout", "Вернуть сохраненную компоновку"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Сохранить изменения этой рабочей среды"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Сохранить как новую рабочую среду..."), + ("window.editWorkspaces", "Edit Workspaces...", "Редактировать рабочие среды..."), + ("", "Assign Shortcut to Workspace", "Назначить сочетание клавиш рабочей среде"), + ("window.panel", "Align", "Выравнивание"), + ("window.panel", "Audio", "Звук"), + ("window.panel", "Brushes", "Кисти"), + ("window.panel", "Character", "Символ"), + ("window.panel", "Content-Aware Fill", "Заливка с учетом содержимого"), + ("window.panel", "Effects & Presets", "Эффекты и заготовки"), + ("window.panel", "Essential Graphics", "Основная графика"), + ("window.panel", "Info", "Информация"), + ("help.inAppTutorials", "Learn", "Обучение"), + ("window.panel", "Lumetri Scopes", "Измерители Lumetri"), + ("window.panel", "Mask Interpolation", "Интерполяция маски"), + ("window.panel", "Media Browser", "Обозреватель медиа"), + ("window.panel", "Metadata", "Метаданные"), + ("window.panel", "Motion Sketch", "Эскиз движения"), + ("window.panel", "Paint", "Рисование"), + ("window.panel", "Paragraph", "Абзац"), + ("window.panel", "Preview", "Предварительный просмотр"), + ("window.panel", "Progress", "Ход выполнения"), + ("window.panel", "Properties", "Свойства"), + ("window.panel", "Script Console", "Консоль скриптов"), + ("window.panel", "Smoother", "Сглаживание"), + ("window.panel", "Tools", "Инструменты"), + ("window.panel", "Tracker", "Трекер"), + ("window.panel", "Wiggler", "Колебания"), + ("window.panel", "Composition", "Композиция"), + ("window.panel", "Flowchart", "Блок-схема"), + ("window.panel", "Footage", "Материал"), + ("window.panel", "Layer", "Слой"), + ("window.panel", "Project", "Проект"), + ("window.panel", "Render Queue", "Очередь визуализации"), + ("window.panel", "Timeline", "Шкала времени"), + ("window.panel", "Create Nulls From Paths", "Создание нулевых объектов из контуров"), + ("window.panel", "VR Comp Editor", "Редактор VR-композиций"), + ("", "Help", "Справка"), + ("help.docs", "EffectCraft Help...", "Справка EffectCraft..."), + ("help.docs", "Scripting Help...", "Справка по скриптам..."), + ("help.docs", "Expression Reference...", "Справочник по выражениям..."), + ("help.docs", "Effect Reference...", "Справочник по эффектам..."), + ("anim.browsePresets", "Animation Presets...", "Заготовки анимации..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Сочетания клавиш..."), + ("help.inAppTutorials", "In-App Tutorials...", "Уроки в приложении..."), + ("help.onlineTutorials", "Online Tutorials...", "Онлайн-уроки..."), + ("help.systemReport", "System Compatibility Report...", "Отчет о совместимости системы..."), + ("help.enableLogging", "Enable Logging", "Включить ведение журнала"), + ("help.revealLogFile", "Reveal Logging File", "Показать файл журнала"), + ("help.discord", "Join the ArtCraft Discord...", "Присоединяйтесь к Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Оставить отзыв..."), + ("help.website", "ArtCraft Website", "Сайт ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Главная страница EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft на GitHub"), + ("file.openDemoProject", "Open Demo Project", "Открыть демонстрационный проект"), +]; + +/// French (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const FRENCH: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Copier l'image dans le presse-papiers"), + ("", "Front", "Avant"), + ("", "Bevel", "Biseau"), + ("", "Side", "Côté"), + ("", "Back", "Arrière"), + ("layer.addTextAnimator", "Color", "Couleur"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "À propos d’EffectCraft..."), + ("", "Settings...", "Préférences..."), + ("app.settings", "General...", "Général..."), + ("app.settings", "Startup & Repair...", "Démarrage et réparation..."), + ("app.settings", "Project...", "Projet..."), + ("app.settings", "Composition...", "Composition..."), + ("app.settings", "Previews...", "Prévisualisations..."), + ("app.settings", "Appearance...", "Apparence..."), + ("app.settings", "Grids & Guides...", "Grilles et repères..."), + ("app.settings", "Labels...", "Étiquettes..."), + ("app.settings", "Type...", "Texte..."), + ("app.settings", "Import...", "Importation..."), + ("app.settings", "Export...", "Exportation..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Disque..."), + ("app.settings", "Memory & CPU...", "Mémoire et CPU..."), + ("app.settings", "Video...", "Vidéo..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Scripts et expressions..."), + ("app.hide", "Hide EffectCraft", "Masquer EffectCraft"), + ("app.hideOthers", "Hide Others", "Masquer les autres"), + ("app.showAll", "Show All", "Tout afficher"), + ("app.quit", "Quit EffectCraft", "Quitter EffectCraft"), + ("", "File", "Fichier"), + ("", "New", "Nouveau"), + ("file.newProject", "New Project", "Nouveau projet"), + ("file.newFromTemplate", "New Project from Template...", "Nouveau projet à partir d’un modèle..."), + ("project.newFolder", "New Folder", "Nouveau dossier"), + ("file.open", "Open Project...", "Ouvrir un projet..."), + ("", "Open Recent", "Ouvrir les projets récents"), + ("file.clearRecent", "Clear Recent Projects", "Effacer la liste des projets récents"), + ("file.close", "Close", "Fermer"), + ("file.closeProject", "Close Project", "Fermer le projet"), + ("file.save", "Save", "Enregistrer"), + ("", "Save As", "Enregistrer sous"), + ("file.saveAs", "Save As...", "Enregistrer sous..."), + ("file.saveCopy", "Save a Copy...", "Enregistrer une copie..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Enregistrer une copie en XML..."), + ("templates.saveAs", "Save as Template...", "Enregistrer en tant que modèle..."), + ("file.incrementAndSave", "Increment and Save", "Incrémenter et enregistrer"), + ("file.revert", "Revert", "Version précédente"), + ("", "Import", "Importer"), + ("file.import", "File...", "Fichier..."), + ("file.importMultiple", "Multiple Files...", "Plusieurs fichiers..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Espace réservé..."), + ("file.importSolid", "Solid...", "Solide..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Point de fuite (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Modèle de graphismes essentiels..."), + ("", "Import Recent Footage", "Importer le métrage récent"), + ("file.clearRecentFootage", "Clear Recent Footage", "Effacer la liste des métrages récents"), + ("", "Export", "Exporter"), + ("renderQueue.add", "Add to Render Queue", "Ajouter à la file d’attente de rendu"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Modèle de graphismes essentiels..."), + ("app.find", "Find", "Rechercher"), + ("layer.addItem", "Add Footage to Comp", "Ajouter le métrage à la composition"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nouvelle composition à partir de la sélection..."), + ("", "Dependencies", "Dépendances"), + ("file.collectFiles", "Collect Files...", "Collecter les fichiers..."), + ("file.consolidateFootage", "Consolidate All Footage", "Consolider tout le métrage"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Supprimer le métrage inutilisé"), + ("file.reduceProject", "Reduce Project", "Réduire le projet"), + ("file.findMissing", "Find Missing Effects", "Rechercher les effets manquants"), + ("file.findMissing", "Find Missing Fonts", "Rechercher les polices manquantes"), + ("file.findMissing", "Find Missing Footage", "Rechercher le métrage manquant"), + ("file.relinkFootage", "Relink Missing Footage...", "Reconnecter le métrage manquant..."), + ("file.watchFolder", "Watch Folder...", "Dossier de contrôle..."), + ("", "Scripts", "Scripts"), + ("file.installScript", "Install Script File...", "Installer un fichier de script..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."), + ("file.runScript", "Run Script File...", "Exécuter un fichier de script..."), + ("", "Create Proxy", "Créer un proxy"), + ("file.createProxy", "Still...", "Image fixe..."), + ("file.createProxy", "Movie...", "Vidéo..."), + ("", "Set Proxy", "Définir le proxy"), + ("file.setProxy", "File...", "Fichier..."), + ("file.setProxyNone", "None", "Aucun"), + ("", "Interpret Footage", "Interpréter le métrage"), + ("file.interpretFootage", "Main...", "Principal..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Mémoriser l’interprétation"), + ("file.applyInterpretation", "Apply Interpretation", "Appliquer l’interprétation"), + ("", "Replace Footage", "Remplacer le métrage"), + ("file.replaceFootage", "File...", "Fichier..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Espace réservé..."), + ("file.replaceWithSolid", "Solid...", "Solide..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Par une composition multi-calques"), + ("file.reloadFootage", "Reload Footage", "Recharger le métrage"), + ("file.revealInFinder", "Reveal in Finder", "Révéler dans le Finder"), + ("file.projectSettings", "Project Settings...", "Paramètres du projet..."), + ("app.quit", "Exit", "Quitter"), + ("", "Edit", "Édition"), + ("edit.undo", "Undo", "Annuler"), + ("edit.redo", "Redo", "Rétablir"), + ("", "History", "Historique"), + ("app.commandPalette", "Quick Apply...", "Application rapide..."), + ("edit.cut", "Cut", "Couper"), + ("edit.copy", "Copy", "Copier"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Copier avec liens de propriétés"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Copier avec liens de propriétés relatifs"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Copier uniquement l’expression"), + ("edit.paste", "Paste", "Coller"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Coller les images clés inversées"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Coller le texte avec la mise en forme correspondante"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Coller uniquement la mise en forme du texte"), + ("edit.clear", "Clear", "Effacer"), + ("edit.duplicate", "Duplicate", "Dupliquer"), + ("edit.splitLayer", "Split Layer", "Fractionner le calque"), + ("edit.liftWorkArea", "Lift Work Area", "Lever la zone de travail"), + ("edit.extractWorkArea", "Extract Work Area", "Extraire la zone de travail"), + ("edit.selectAll", "Select All", "Tout sélectionner"), + ("edit.deselectAll", "Deselect All", "Tout désélectionner"), + ("", "Label", "Étiquette"), + ("edit.selectLabelGroup", "Select Label Group", "Sélectionner le groupe d’étiquettes"), + ("edit.label", "None", "Aucune"), + ("edit.label", "Red", "Rouge"), + ("edit.label", "Yellow", "Jaune"), + ("edit.label", "Aqua", "Aqua"), + ("edit.label", "Pink", "Rose"), + ("edit.label", "Lavender", "Lavande"), + ("edit.label", "Peach", "Pêche"), + ("edit.label", "Sea Foam", "Vert d’eau"), + ("edit.label", "Blue", "Bleu"), + ("edit.label", "Green", "Vert"), + ("edit.label", "Purple", "Violet"), + ("edit.label", "Orange", "Orange"), + ("edit.label", "Brown", "Marron"), + ("edit.label", "Fuchsia", "Fuchsia"), + ("edit.label", "Cyan", "Cyan"), + ("edit.label", "Sandstone", "Sable"), + ("edit.label", "Dark Green", "Vert foncé"), + ("app.settings", "Edit Label Colors...", "Modifier les couleurs des étiquettes..."), + ("", "Select Keyframe Label Group", "Sélectionner le groupe d’étiquettes d’images clés"), + ("keys.selectLabelGroup", "On Selected Layers", "Sur les calques sélectionnés"), + ("keys.selectLabelGroup", "On All Layers", "Sur tous les calques"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Images clés visibles sur les calques sélectionnés"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Images clés visibles sur tous les calques"), + ("", "Purge", "Vider"), + ("edit.purge", "All Cache...", "Tout le cache..."), + ("edit.purge", "All Memory & Disk Cache...", "Toute la mémoire et le cache disque..."), + ("edit.purge", "All Memory", "Toute la mémoire"), + ("edit.purge", "All Disk Cache...", "Tout le cache disque..."), + ("edit.purge", "All 3D Cache...", "Tout le cache 3D..."), + ("edit.purgeUndo", "Undo", "Historique d’annulation"), + ("edit.purge", "Image Cache Memory", "Mémoire cache des images"), + ("edit.purge", "Snapshot", "Instantané"), + ("edit.editOriginal", "Edit Original...", "Modifier l’original..."), + ("", "Templates", "Modèles"), + ("app.templates", "Render Settings...", "Paramètres de rendu..."), + ("app.templates", "Output Module...", "Module de sortie..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Raccourcis clavier"), + ("", "Preferences", "Préférences"), + ("", "Composition", "Composition"), + ("comp.new", "New Composition...", "Nouvelle composition..."), + ("comp.settings", "Composition Settings...", "Paramètres de composition..."), + ("comp.setPosterTime", "Set Poster Time", "Définir l’image affiche"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Raccourcir la composition à la zone de travail"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Rogner la composition à la zone d’intérêt"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Rogner la composition aux limites des calques sélectionnés"), + ("render.addOutputModule", "Add Output Module", "Ajouter un module de sortie"), + ("", "Preview", "Aperçu"), + ("playback.toggle", "Play Current Preview", "Lire l’aperçu actuel"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Mettre en cache les images en cas d’inactivité"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Enregistrer l’image sous"), + ("comp.saveFrameAs", "File...", "Fichier..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Calques Photoshop..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Prérendu..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Enregistrer l’aperçu actuel..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Ouvrir dans Graphiques essentiels"), + ("", "Responsive Design — Time", "Responsive Design — Temps"), + ("comp.responsiveTime", "Create Intro", "Créer une intro"), + ("comp.responsiveTime", "Create Outro", "Créer une outro"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Créer une région protégée à partir de la zone de travail"), + ("comp.flowchart", "Composition Flowchart", "Organigramme de composition"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Mini-organigramme de composition"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Créer un environnement VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extraire le cubemap..."), + ("", "Layer", "Calque"), + ("layer.newText", "Text", "Texte"), + ("layer.newSolid", "Solid...", "Solide..."), + ("layer.newLight", "Light...", "Lumière..."), + ("layer.newCamera", "Camera...", "Caméra..."), + ("layer.newNull", "Null Object", "Objet nul"), + ("layer.newShape", "Shape Layer", "Calque de forme"), + ("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."), + ("layer.new3dPrimitive", "Cube", "Cube"), + ("layer.new3dPrimitive", "Sphere", "Sphère"), + ("layer.new3dPrimitive", "Plane", "Plan"), + ("layer.new3dPrimitive", "Torus", "Tore"), + ("layer.new3dPrimitive", "Cone", "Cône"), + ("layer.new3dPrimitive", "Cylinder", "Cylindre"), + ("layer.settings", "Layer Settings...", "Paramètres du calque..."), + ("layer.openLayer", "Open Layer", "Ouvrir le calque"), + ("layer.openSource", "Open Layer Source", "Ouvrir la source du calque"), + ("layer.revealInFinder", "Reveal in Finder", "Afficher dans le Finder"), + ("", "Mask", "Masque"), + ("layer.addMask", "New Mask", "Nouveau masque"), + ("layer.mask.shape", "Mask Shape...", "Forme du masque..."), + ("layer.mask.set", "Mask Feather...", "Contour progressif du masque..."), + ("layer.mask.set", "Mask Opacity...", "Opacité du masque..."), + ("layer.mask.set", "Mask Expansion...", "Extension du masque..."), + ("layer.mask.reset", "Reset Mask", "Réinitialiser le masque"), + ("layer.mask.remove", "Remove Mask", "Supprimer le masque"), + ("layer.mask.removeAll", "Remove All Masks", "Supprimer tous les masques"), + ("track.mask", "Track Mask", "Suivi du masque"), + ("", "Mode", "Mode"), + ("layer.mask.mode", "None", "Aucun"), + ("layer.mask.mode", "Add", "Ajouter"), + ("layer.mask.mode", "Subtract", "Soustraire"), + ("layer.mask.mode", "Intersect", "Intersection"), + ("layer.mask.mode", "Lighten", "Éclaircir"), + ("layer.mask.mode", "Darken", "Obscurcir"), + ("layer.mask.mode", "Difference", "Différence"), + ("layer.mask.invert", "Inverted", "Inversé"), + ("layer.mask.lock", "Locked", "Verrouillé"), + ("", "Motion Blur", "Flou de mouvement"), + ("layer.mask.motionBlur", "Same As Layer", "Identique au calque"), + ("layer.mask.motionBlur", "On", "Activé"), + ("layer.mask.motionBlur", "Off", "Désactivé"), + ("", "Feather Falloff", "Atténuation du contour progressif"), + ("layer.mask.featherFalloff", "Smooth", "Lisse"), + ("layer.mask.featherFalloff", "Linear", "Linéaire"), + ("layer.mask.unlockAll", "Unlock All Masks", "Déverrouiller tous les masques"), + ("layer.mask.lockOthers", "Lock Other Masks", "Verrouiller les autres masques"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Masquer les masques verrouillés"), + ("", "Mask and Shape Path", "Trajectoire de masque et de forme"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Fermé"), + ("path.convertToBezier", "Convert To Bezier Path", "Convertir en trajectoire Bézier"), + ("path.setFirstVertex", "Set First Vertex", "Définir le premier sommet"), + ("path.freeTransform", "Free Transform Points", "Transformation libre des points"), + ("", "Quality", "Qualité"), + ("layer.quality", "Best", "Optimale"), + ("layer.quality", "Draft", "Brouillon"), + ("layer.quality", "Wireframe", "Fil de fer"), + ("layer.sampling", "Bilinear", "Bilinéaire"), + ("layer.sampling", "Bicubic", "Bicubique"), + ("", "Switches", "Commutateurs"), + ("layer.hideOtherVideo", "Hide Other Video", "Masquer les autres vidéos"), + ("layer.showAllVideo", "Show All Video", "Afficher toutes les vidéos"), + ("layer.unlockAll", "Unlock All Layers", "Déverrouiller tous les calques"), + ("layer.expressions", "Enable Expressions", "Activer les expressions"), + ("layer.expressions", "Disable Expressions", "Désactiver les expressions"), + ("layer.setSwitch", "Shy", "Timide"), + ("layer.setSwitch", "Lock", "Verrouiller"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Vidéo"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Effet"), + ("layer.setSwitch", "Collapse", "Réduire les transformations"), + ("layer.setSwitch", "Motion Blur", "Flou de mouvement"), + ("layer.setSwitch", "Adjustment Layer", "Calque de réglage"), + ("", "Transform", "Transformation"), + ("layer.transform", "Reset", "Réinitialiser"), + ("layer.setTransform", "Anchor Point...", "Point d’ancrage..."), + ("layer.setTransform", "Position...", "Position..."), + ("layer.setTransform", "Scale...", "Échelle..."), + ("layer.setTransform", "Orientation...", "Orientation..."), + ("layer.setTransform", "Rotation...", "Rotation..."), + ("layer.setTransform", "Opacity...", "Opacité..."), + ("layer.transform", "Flip Horizontal", "Miroir horizontal"), + ("layer.transform", "Flip Vertical", "Miroir vertical"), + ("layer.transform", "Center In View", "Centrer dans la vue"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Centrer le point d’ancrage dans le contenu du calque"), + ("layer.transform", "Fit to Comp", "Ajuster à la composition"), + ("layer.transform", "Fit to Comp Width", "Ajuster à la largeur de la composition"), + ("layer.transform", "Fit to Comp Height", "Ajuster à la hauteur de la composition"), + ("layer.autoOrient", "Auto-Orient...", "Orientation automatique..."), + ("", "Time", "Temps"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Activer le remappage temporel"), + ("layer.timeReverse", "Time-Reverse Layer", "Inverser le temps du calque"), + ("layer.timeStretch", "Time Stretch...", "Extension temporelle..."), + ("layer.freezeFrame", "Freeze Frame", "Image figée"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Figer à la dernière image"), + ("layer.alignVideoToData", "Align Video to Data", "Aligner la vidéo sur les données"), + ("", "Frame Blending", "Fusion d’images"), + ("layer.frameBlending", "Off", "Désactivé"), + ("layer.frameBlending", "Frame Mix", "Mélange d’images"), + ("layer.frameBlending", "Pixel Motion", "Mouvement de pixel"), + ("layer.setSwitch", "3D Layer", "Calque 3D"), + ("layer.setSwitch", "Guide Layer", "Calque de repère"), + ("layer.environment", "Environment Layer", "Calque d’environnement"), + ("", "Markers", "Repères"), + ("layer.addMarker", "Add Marker", "Ajouter un repère"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Mettre à jour les repères à partir de la source"), + ("layer.markersLock", "Lock Markers", "Verrouiller les repères"), + ("layer.deleteAllMarkers", "Delete All Markers", "Supprimer tous les repères"), + ("layer.setSwitch", "Preserve Transparency", "Conserver la transparence"), + ("", "Blending Mode", "Mode de fusion"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Fondu"), + ("layer.setBlendMode", "Dancing Dissolve", "Fondu dansant"), + ("layer.setBlendMode", "Darken", "Obscurcir"), + ("layer.setBlendMode", "Multiply", "Produit"), + ("layer.setBlendMode", "Color Burn", "Densité couleur +"), + ("layer.setBlendMode", "Classic Color Burn", "Densité couleur + classique"), + ("layer.setBlendMode", "Linear Burn", "Densité linéaire +"), + ("layer.setBlendMode", "Darker Color", "Couleur plus foncée"), + ("layer.setBlendMode", "Add", "Ajouter"), + ("layer.setBlendMode", "Lighten", "Éclaircir"), + ("layer.setBlendMode", "Screen", "Superposition"), + ("layer.setBlendMode", "Color Dodge", "Densité couleur -"), + ("layer.setBlendMode", "Classic Color Dodge", "Densité couleur - (classique)"), + ("layer.setBlendMode", "Linear Dodge", "Densité linéaire -"), + ("layer.setBlendMode", "Lighter Color", "Couleur plus claire"), + ("layer.setBlendMode", "Overlay", "Incrustation"), + ("layer.setBlendMode", "Soft Light", "Lumière tamisée"), + ("layer.setBlendMode", "Hard Light", "Lumière crue"), + ("layer.setBlendMode", "Linear Light", "Lumière linéaire"), + ("layer.setBlendMode", "Vivid Light", "Lumière vive"), + ("layer.setBlendMode", "Pin Light", "Lumière ponctuelle"), + ("layer.setBlendMode", "Hard Mix", "Mélange maximal"), + ("layer.setBlendMode", "Difference", "Différence"), + ("layer.setBlendMode", "Classic Difference", "Différence classique"), + ("layer.setBlendMode", "Exclusion", "Exclusion"), + ("layer.setBlendMode", "Subtract", "Soustraction"), + ("layer.setBlendMode", "Divide", "Division"), + ("layer.setBlendMode", "Hue", "Teinte"), + ("layer.setBlendMode", "Saturation", "Saturation"), + ("layer.setBlendMode", "Color", "Couleur"), + ("layer.setBlendMode", "Luminosity", "Luminosité"), + ("layer.setBlendMode", "Stencil Alpha", "Modèle alpha"), + ("layer.setBlendMode", "Stencil Luma", "Luminance du modèle"), + ("layer.setBlendMode", "Silhouette Alpha", "Silhouette alpha"), + ("layer.setBlendMode", "Silhouette Luma", "Luminance de silhouette"), + ("layer.setBlendMode", "Alpha Add", "Ajout alpha"), + ("layer.setBlendMode", "Luminescent Premul", "Luminescent prémultiplié"), + ("layer.setBlendMode", "Next Blending Mode", "Mode de fusion suivant"), + ("layer.setBlendMode", "Previous Blending Mode", "Mode de fusion précédent"), + ("", "Track Matte", "Estampe"), + ("layer.trackMatte", "No Track Matte", "Aucune estampe"), + ("layer.trackMatte", "Alpha Matte", "Estampe alpha"), + ("layer.trackMatte", "Alpha Inverted Matte", "Estampe alpha inversée"), + ("layer.trackMatte", "Luma Matte", "Estampe luma"), + ("layer.trackMatte", "Luma Inverted Matte", "Estampe luma inversée"), + ("layer.trackMatte", "Matte with Layer Above", "Estampe avec le calque au-dessus"), + ("layer.trackMatte", "Matte with Layer Below", "Estampe avec le calque en dessous"), + ("", "Layer Styles", "Styles de calque"), + ("layer.style.options", "Layer Style Options...", "Options de style de calque..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Convertir en styles modifiables"), + ("layer.style.showAll", "Show All", "Tout afficher"), + ("layer.style.removeAll", "Remove All", "Tout supprimer"), + ("layer.style.dropShadow", "Drop Shadow", "Ombre portée"), + ("layer.style.innerShadow", "Inner Shadow", "Ombre interne"), + ("layer.style.outerGlow", "Outer Glow", "Lueur externe"), + ("layer.style.innerGlow", "Inner Glow", "Lueur interne"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Biseautage et estampage"), + ("layer.style.satin", "Satin", "Satin"), + ("layer.style.colorOverlay", "Color Overlay", "Incrustation couleur"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Incrustation en dégradé"), + ("layer.style.stroke", "Stroke", "Contour"), + ("path.groupShapes", "Group Shapes", "Grouper les formes"), + ("path.ungroupShapes", "Ungroup Shapes", "Dissocier les formes"), + ("", "Arrange", "Disposition"), + ("layer.arrange", "Bring Layer to Front", "Placer le calque au premier plan"), + ("layer.arrange", "Bring Layer Forward", "Avancer le calque d'un cran"), + ("layer.arrange", "Send Layer Backward", "Reculer le calque d'un cran"), + ("layer.arrange", "Send Layer to Back", "Placer le calque en arrière-plan"), + ("", "Reveal", "Révéler"), + ("layer.revealSource", "Reveal Layer Source in Project", "Révéler la source du calque dans le projet"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Révéler le calque dans l'organigramme du projet"), + ("comp.revealInProject", "Reveal Composition in Project", "Révéler la composition dans le projet"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Révéler les erreurs d'expression"), + ("", "Create", "Créer"), + ("layer.create", "Convert to Editable Text", "Convertir en texte modifiable"), + ("layer.create", "Create Shapes from Text", "Créer des formes à partir du texte"), + ("layer.create", "Create Masks from Text", "Créer des masques à partir du texte"), + ("layer.create", "Create Shapes from Vector Layer", "Créer des formes à partir du calque vectoriel"), + ("layer.create", "Create Keyframes from Data", "Créer des images clés à partir des données"), + ("layer.create", "Null Controllers for Positional Points", "Objets nuls de contrôle pour les points de position"), + ("layer.create", "Null Controllers for Path Points", "Objets nuls de contrôle pour les points de tracé"), + ("layer.create", "Nulls Following Path Points", "Objets nuls suivant les points du tracé"), + ("layer.create", "Null Tracing Along Path", "Objet nul se déplaçant le long du tracé"), + ("layer.create", "Create 3D Layer Instance", "Créer une instance de calque 3D"), + ("", "Camera", "Caméra"), + ("camera.fromView", "Create Camera from 3D View", "Créer une caméra à partir de la vue 3D"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Créer un rig stéréo 3D"), + ("camera.orbitNull", "Create Orbit Null", "Créer un objet nul d'orbite"), + ("camera.fromModel", "Create Cameras from 3D Model", "Créer des caméras à partir du modèle 3D"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Lier la distance focale au point d'intérêt"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Lier la distance focale au calque"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Définir la distance focale sur le calque"), + ("layer.cameraSettings", "Camera Settings...", "Paramètres de caméra..."), + ("view.reset3DView", "Reset 3D View", "Réinitialiser la vue 3D"), + ("", "Light", "Lumière"), + ("light.fromModel", "Create Lights from 3D Model", "Créer des lumières à partir du modèle 3D"), + ("light.controlWithCamera", "Control Light with Camera", "Contrôler la lumière avec la caméra"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Créer un calque d'arrière-plan de lumière d'environnement"), + ("", "Material", "Matériau"), + ("material.revealSource", "Reveal Material Source in Project", "Révéler la source du matériau dans le projet"), + ("material.reset", "Reset Material", "Réinitialiser le matériau"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Dupliquer et attribuer le matériau"), + ("layer.autoTrace", "Auto-trace...", "Tracé automatique..."), + ("layer.precompose", "Pre-compose...", "Précomposer..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Détection des coupes de scène..."), + ("", "Effect", "Effet"), + ("window.panel", "Effect Controls", "Effets appliqués"), + ("effect.applyLast", "Last Effect", "Dernier effet"), + ("effect.removeAll", "Remove All", "Tout supprimer"), + ("effect.manage", "Manage Effects...", "Gérer les effets..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Charger un plug-in d'effet..."), + ("", "Animation", "Animation"), + ("anim.savePreset", "Save Animation Preset...", "Enregistrer une animation prédéfinie..."), + ("anim.applyPreset", "Apply Animation Preset...", "Appliquer une animation prédéfinie..."), + ("", "Recent Animation Presets", "Animations prédéfinies récentes"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Effacer les animations prédéfinies récentes"), + ("anim.browsePresets", "Browse Presets...", "Parcourir les animations prédéfinies..."), + ("", "Text Animation Presets", "Animations prédéfinies de texte"), + ("layer.applyTextPreset", "Typewriter", "Machine à écrire"), + ("layer.applyTextPreset", "Fade Up Characters", "Fondu entrant des caractères"), + ("layer.applyTextPreset", "Bounce In Words", "Apparition des mots avec rebond"), + ("layer.applyTextPreset", "Tracking In", "Interlettrage progressif"), + ("layer.applyTextPreset", "Scramble", "Brouillage"), + ("layer.applyTextPreset", "Blur In", "Flou entrant"), + ("layer.applyTextPreset", "Jitter", "Tremblement"), + ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), + ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), + ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), + ("", "Keyframe Assistant", "Assistant d'images clés"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Convertir l'audio en images clés"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Convertir l'expression en images clés"), + ("keys.easyEase", "Easy Ease", "Lissage"), + ("keys.easyEaseIn", "Easy Ease In", "Lissage entrant"), + ("keys.easyEaseOut", "Easy Ease Out", "Lissage sortant"), + ("keys.exponentialScale", "Exponential Scale", "Échelle exponentielle"), + ("keys.rpfCameraImport", "RPF Camera Import", "Importation de caméra RPF"), + ("layer.sequence", "Sequence Layers...", "Enchaîner les calques..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Inverser le temporel des images clés"), + ("", "Animate Text", "Animer le texte"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Activer la 3D par caractère"), + ("layer.addTextAnimator", "Anchor Point", "Point d'ancrage"), + ("layer.addTextAnimator", "Position", "Position"), + ("layer.addTextAnimator", "Scale", "Échelle"), + ("layer.addTextAnimator", "Skew", "Inclinaison"), + ("layer.addTextAnimator", "Rotation", "Rotation"), + ("layer.addTextAnimator", "Opacity", "Opacité"), + ("layer.addTextAnimator", "All Transform Properties", "Toutes les propriétés de transformation"), + ("", "Fill Color", "Couleur de remplissage"), + ("layer.addTextAnimator", "RGB", "RVB"), + ("layer.addTextAnimator", "Hue", "Teinte"), + ("layer.addTextAnimator", "Saturation", "Saturation"), + ("layer.addTextAnimator", "Brightness", "Luminosité"), + ("", "Stroke Color", "Couleur du contour"), + ("layer.addTextAnimator", "Stroke Width", "Épaisseur du contour"), + ("layer.addTextAnimator", "Tracking", "Interlettrage"), + ("layer.addTextAnimator", "Line Anchor", "Ancrage de ligne"), + ("layer.addTextAnimator", "Line Spacing", "Interligne"), + ("layer.addTextAnimator", "Character Offset", "Décalage de caractère"), + ("layer.addTextAnimator", "Character Value", "Valeur de caractère"), + ("layer.addTextAnimator", "Blur", "Flou"), + ("text.animatorFontAxes", "Variable Font Axes", "Axes de police variable"), + ("", "Add Text Selector", "Ajouter un sélecteur de texte"), + ("text.addSelector", "Range", "Plage"), + ("text.addSelector", "Wiggly", "Ondulant"), + ("text.addSelector", "Expression", "Expression"), + ("text.removeAllAnimators", "Remove All Text Animators", "Supprimer tous les animateurs de texte"), + ("prop.setExpression", "Add Expression", "Ajouter une expression"), + ("essential.addProperty", "Add Property to Essential Graphics", "Ajouter la propriété aux graphiques essentiels"), + ("prop.separateDimensions", "Separate Dimensions", "Séparer les dimensions"), + ("track.camera", "Track Camera", "Suivi de caméra"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Stabilisateur de déformation VFX"), + ("track.motion", "Track Motion", "Suivi du mouvement"), + ("track.stabilize", "Stabilize Motion", "Stabiliser le mouvement"), + ("track.property", "Track this Property", "Effectuer le suivi de cette propriété"), + ("anim.reveal", "Reveal Properties with Keyframes", "Révéler les propriétés avec images clés"), + ("anim.reveal", "Reveal Properties with Animation", "Révéler les propriétés avec animation"), + ("anim.reveal", "Reveal All Modified Properties", "Révéler toutes les propriétés modifiées"), + ("", "View", "Affichage"), + ("view.newViewer", "New Viewer", "Nouveau visualiseur"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Fractionner avec un nouveau visualiseur verrouillé"), + ("view.zoomIn", "Zoom In", "Zoom avant"), + ("view.zoomOut", "Zoom Out", "Zoom arrière"), + ("", "Resolution", "Résolution"), + ("view.res.full", "Full", "Complète"), + ("view.res.half", "Half", "Moitié"), + ("view.res.third", "Third", "Tiers"), + ("view.res.quarter", "Quarter", "Quart"), + ("view.res.custom", "Custom...", "Personnalisée..."), + ("view.displayColorManagement", "Use Display Color Management", "Utiliser la gestion des couleurs de l’écran"), + ("", "Simulate Output", "Simuler la sortie"), + ("view.simulateOutput", "No Output Simulation", "Aucune simulation de sortie"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "RVB Macintosh hérité (gamma 1,8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "RVB Internet standard (sRVB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Linéaire (gamma 1,0)"), + ("view.customRgb", "My Custom RGB...", "Mon RVB personnalisé..."), + ("view.simulateOutput", "Custom...", "Personnalisé..."), + ("view.rulers", "Show Rulers", "Afficher les règles"), + ("", "Panel Background Color", "Couleur d’arrière-plan du panneau"), + ("view.panelBackground", "Black", "Noir"), + ("view.panelBackground", "Dark Gray", "Gris foncé"), + ("view.panelBackground", "Medium Gray (Default)", "Gris moyen (par défaut)"), + ("view.panelBackground", "Light Gray", "Gris clair"), + ("view.panelBackground", "White", "Blanc"), + ("view.panelBackground", "Custom", "Personnalisée"), + ("view.panelBackground", "Select Custom Background Color...", "Sélectionner une couleur d’arrière-plan personnalisée..."), + ("view.guides", "Show Guides", "Afficher les repères"), + ("view.snapToGuides", "Snap to Guides", "Aimanter sur les repères"), + ("view.lockGuides", "Lock Guides", "Verrouiller les repères"), + ("view.addGuide", "Add Guide...", "Ajouter un repère..."), + ("view.clearGuides", "Clear Guides", "Effacer les repères"), + ("view.importGuides", "Import Guides...", "Importer des repères..."), + ("view.exportGuides", "Export Guides...", "Exporter des repères..."), + ("view.grid", "Show Grid", "Afficher la grille"), + ("view.snapToGrid", "Snap to Grid", "Aimanter sur la grille"), + ("view.snapping", "Snapping", "Aimantation"), + ("view.options", "View Options...", "Options d’affichage..."), + ("view.layerControls", "Show Layer Controls", "Afficher les contrôles de calque"), + ("", "Switch View Layout", "Changer la disposition des vues"), + ("view.layout", "1 View", "1 vue"), + ("view.layout", "2 Views", "2 vues"), + ("view.layout", "4 Views", "4 vues"), + ("view.shareViewOptions", "Share View Options", "Partager les options d’affichage"), + ("", "Switch 3D View", "Changer de vue 3D"), + ("view.3d.activeCamera", "Active Camera", "Caméra active"), + ("view.3d.default", "Default", "Par défaut"), + ("view.3d.front", "Front", "Face"), + ("view.3d.left", "Left", "Gauche"), + ("view.3d.top", "Top", "Dessus"), + ("view.3d.back", "Back", "Arrière"), + ("view.3d.right", "Right", "Droite"), + ("view.3d.bottom", "Bottom", "Dessous"), + ("view.3d.custom1", "Custom View 1", "Vue personnalisée 1"), + ("view.3d.custom2", "Custom View 2", "Vue personnalisée 2"), + ("view.3d.custom3", "Custom View 3", "Vue personnalisée 3"), + ("", "Assign Shortcut to 3D View", "Attribuer un raccourci à la vue 3D"), + ("view.3d.last", "Switch to Last 3D View", "Basculer vers la dernière vue 3D"), + ("view.lookAtSelected", "Look at Selected Layers", "Pointer sur les calques sélectionnés"), + ("view.lookAtAll", "Look at All Layers", "Pointer sur tous les calques"), + ("time.set", "Go to Time...", "Accéder au temps..."), + ("view.fullScreen", "Enter Full Screen", "Passer en plein écran"), + ("", "Window", "Fenêtre"), + ("", "Workspace", "Espace de travail"), + ("window.workspace", "Default", "Par défaut"), + ("window.workspace", "Review", "Révision"), + ("window.workspace", "Learn", "Apprentissage"), + ("window.workspace", "Small Screen", "Petit écran"), + ("window.workspace", "Standard", "Standard"), + ("window.workspace", "All Panels", "Tous les panneaux"), + ("window.workspace", "Animation", "Animation"), + ("window.workspace", "Color", "Couleur"), + ("window.workspace", "Effects", "Effets"), + ("window.workspace", "Essential Graphics", "Graphiques essentiels"), + ("window.workspace", "Minimal", "Minimal"), + ("window.workspace", "Motion Tracking", "Suivi de mouvement"), + ("window.workspace", "Paint", "Peinture"), + ("window.workspace", "Text", "Texte"), + ("window.workspace", "Undocked Panels", "Panneaux détachés"), + ("window.resetWorkspace", "Reset to Saved Layout", "Rétablir la disposition enregistrée"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Enregistrer les modifications de cet espace de travail"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Enregistrer comme nouvel espace de travail..."), + ("window.editWorkspaces", "Edit Workspaces...", "Modifier les espaces de travail..."), + ("", "Assign Shortcut to Workspace", "Attribuer un raccourci à l’espace de travail"), + ("window.panel", "Align", "Alignement"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pinceaux"), + ("window.panel", "Character", "Caractère"), + ("window.panel", "Content-Aware Fill", "Remplissage d’après le contenu"), + ("window.panel", "Effects & Presets", "Effets et programmes"), + ("window.panel", "Essential Graphics", "Graphiques essentiels"), + ("window.panel", "Info", "Informations"), + ("help.inAppTutorials", "Learn", "Apprentissage"), + ("window.panel", "Lumetri Scopes", "Oscilloscopes Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolation de masque"), + ("window.panel", "Media Browser", "Navigateur multimédia"), + ("window.panel", "Metadata", "Métadonnées"), + ("window.panel", "Motion Sketch", "Croquis de mouvement"), + ("window.panel", "Paint", "Peinture"), + ("window.panel", "Paragraph", "Paragraphe"), + ("window.panel", "Preview", "Prévisualisation"), + ("window.panel", "Progress", "Progression"), + ("window.panel", "Properties", "Propriétés"), + ("window.panel", "Script Console", "Console de scripts"), + ("window.panel", "Smoother", "Lisseur"), + ("window.panel", "Tools", "Outils"), + ("window.panel", "Tracker", "Suivi"), + ("window.panel", "Wiggler", "Onduleur"), + ("window.panel", "Composition", "Composition"), + ("window.panel", "Flowchart", "Organigramme"), + ("window.panel", "Footage", "Métrage"), + ("window.panel", "Layer", "Calque"), + ("window.panel", "Project", "Projet"), + ("window.panel", "Render Queue", "File d’attente de rendu"), + ("window.panel", "Timeline", "Chronologie"), + ("window.panel", "Create Nulls From Paths", "Créer des nuls à partir des tracés"), + ("window.panel", "VR Comp Editor", "Éditeur de composition VR"), + ("", "Help", "Aide"), + ("help.docs", "EffectCraft Help...", "Aide EffectCraft..."), + ("help.docs", "Scripting Help...", "Aide sur les scripts..."), + ("help.docs", "Expression Reference...", "Référence des expressions..."), + ("help.docs", "Effect Reference...", "Référence des effets..."), + ("anim.browsePresets", "Animation Presets...", "Programmes d’animation..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Raccourcis clavier..."), + ("help.inAppTutorials", "In-App Tutorials...", "Tutoriels dans l’application..."), + ("help.onlineTutorials", "Online Tutorials...", "Tutoriels en ligne..."), + ("help.systemReport", "System Compatibility Report...", "Rapport de compatibilité du système..."), + ("help.enableLogging", "Enable Logging", "Activer la journalisation"), + ("help.revealLogFile", "Reveal Logging File", "Révéler le fichier journal"), + ("help.discord", "Join the ArtCraft Discord...", "Rejoindre le Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Envoyer des commentaires..."), + ("help.website", "ArtCraft Website", "Site web ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Page d’accueil EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft sur GitHub"), + ("file.openDemoProject", "Open Demo Project", "Ouvrir le projet de démonstration"), +]; + +/// German (addition): the same (command, English label) keys as `JAPANESE`, one +/// entry per fixed menu entry, in the same order. +const GERMAN: &[(&str, &str, &str)] = &[ + ("comp.copyFrame", "Copy Frame to Clipboard", "Frame in die Zwischenablage kopieren"), + ("", "Front", "Vorderseite"), + ("", "Bevel", "Abschrägung"), + ("", "Side", "Seite"), + ("", "Back", "Rückseite"), + ("layer.addTextAnimator", "Color", "Farbe"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "Über EffectCraft..."), + ("", "Settings...", "Voreinstellungen..."), + ("app.settings", "General...", "Allgemein..."), + ("app.settings", "Startup & Repair...", "Start und Reparatur..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Komposition..."), + ("app.settings", "Previews...", "Vorschauen..."), + ("app.settings", "Appearance...", "Erscheinungsbild..."), + ("app.settings", "Grids & Guides...", "Raster und Hilfslinien..."), + ("app.settings", "Labels...", "Beschriftungen..."), + ("app.settings", "Type...", "Text..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Export..."), + ("app.settings", "Audio...", "Audio..."), + ("app.settings", "Disk...", "Datenträger..."), + ("app.settings", "Memory & CPU...", "Speicher und CPU..."), + ("app.settings", "Video...", "Video..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skripting und Ausdrücke..."), + ("app.hide", "Hide EffectCraft", "EffectCraft ausblenden"), + ("app.hideOthers", "Hide Others", "Andere ausblenden"), + ("app.showAll", "Show All", "Alle einblenden"), + ("app.quit", "Quit EffectCraft", "EffectCraft beenden"), + ("", "File", "Datei"), + ("", "New", "Neu"), + ("file.newProject", "New Project", "Neues Projekt"), + ("file.newFromTemplate", "New Project from Template...", "Neues Projekt aus Vorlage..."), + ("project.newFolder", "New Folder", "Neuer Ordner"), + ("file.open", "Open Project...", "Projekt öffnen..."), + ("", "Open Recent", "Zuletzt verwendet öffnen"), + ("file.clearRecent", "Clear Recent Projects", "Zuletzt verwendete Projekte löschen"), + ("file.close", "Close", "Schließen"), + ("file.closeProject", "Close Project", "Projekt schließen"), + ("file.save", "Save", "Speichern"), + ("", "Save As", "Speichern unter"), + ("file.saveAs", "Save As...", "Speichern unter..."), + ("file.saveCopy", "Save a Copy...", "Kopie speichern..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Kopie als XML speichern..."), + ("templates.saveAs", "Save as Template...", "Als Vorlage speichern..."), + ("file.incrementAndSave", "Increment and Save", "Inkrementell speichern"), + ("file.revert", "Revert", "Wiederherstellen"), + ("", "Import", "Importieren"), + ("file.import", "File...", "Datei..."), + ("file.importMultiple", "Multiple Files...", "Mehrere Dateien..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), + ("file.importPlaceholder", "Placeholder...", "Platzhalter..."), + ("file.importSolid", "Solid...", "Farbfläche..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Fluchtpunkt (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Wesentliche-Grafiken-Vorlage..."), + ("", "Import Recent Footage", "Zuletzt verwendetes Footage importieren"), + ("file.clearRecentFootage", "Clear Recent Footage", "Zuletzt verwendetes Footage löschen"), + ("", "Export", "Exportieren"), + ("renderQueue.add", "Add to Render Queue", "An die Renderliste anfügen"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Wesentliche-Grafiken-Vorlage..."), + ("app.find", "Find", "Suchen"), + ("layer.addItem", "Add Footage to Comp", "Footage zur Komposition hinzufügen"), + ("file.newCompFromSelection", "New Comp from Selection...", "Neue Komposition aus Auswahl..."), + ("", "Dependencies", "Abhängigkeiten"), + ("file.collectFiles", "Collect Files...", "Dateien sammeln..."), + ("file.consolidateFootage", "Consolidate All Footage", "Gesamtes Footage konsolidieren"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Nicht verwendetes Footage entfernen"), + ("file.reduceProject", "Reduce Project", "Projekt reduzieren"), + ("file.findMissing", "Find Missing Effects", "Fehlende Effekte suchen"), + ("file.findMissing", "Find Missing Fonts", "Fehlende Schriftarten suchen"), + ("file.findMissing", "Find Missing Footage", "Fehlendes Footage suchen"), + ("file.relinkFootage", "Relink Missing Footage...", "Fehlendes Footage neu verknüpfen..."), + ("file.watchFolder", "Watch Folder...", "Überwachter Ordner..."), + ("", "Scripts", "Skripte"), + ("file.installScript", "Install Script File...", "Skriptdatei installieren..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."), + ("file.runScript", "Run Script File...", "Skriptdatei ausführen..."), + ("", "Create Proxy", "Proxy erstellen"), + ("file.createProxy", "Still...", "Standbild..."), + ("file.createProxy", "Movie...", "Film..."), + ("", "Set Proxy", "Proxy festlegen"), + ("file.setProxy", "File...", "Datei..."), + ("file.setProxyNone", "None", "Keine"), + ("", "Interpret Footage", "Footage interpretieren"), + ("file.interpretFootage", "Main...", "Haupteinstellungen..."), + ("file.interpretProxy", "Proxy...", "Proxy..."), + ("file.rememberInterpretation", "Remember Interpretation", "Interpretation merken"), + ("file.applyInterpretation", "Apply Interpretation", "Interpretation anwenden"), + ("", "Replace Footage", "Footage ersetzen"), + ("file.replaceFootage", "File...", "Datei..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Platzhalter..."), + ("file.replaceWithSolid", "Solid...", "Farbfläche..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Durch Komposition mit Ebenen"), + ("file.reloadFootage", "Reload Footage", "Footage neu laden"), + ("file.revealInFinder", "Reveal in Finder", "Im Finder anzeigen"), + ("file.projectSettings", "Project Settings...", "Projekteinstellungen..."), + ("app.quit", "Exit", "Beenden"), + ("", "Edit", "Bearbeiten"), + ("edit.undo", "Undo", "Rückgängig"), + ("edit.redo", "Redo", "Wiederholen"), + ("", "History", "Verlauf"), + ("app.commandPalette", "Quick Apply...", "Schnellanwendung..."), + ("edit.cut", "Cut", "Ausschneiden"), + ("edit.copy", "Copy", "Kopieren"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Mit Eigenschaftslinks kopieren"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Mit relativen Eigenschaftslinks kopieren"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Nur Ausdruck kopieren"), + ("edit.paste", "Paste", "Einfügen"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Umgekehrte Keyframes einfügen"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Text mit übereinstimmender Formatierung einfügen"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Nur Textformatierung einfügen"), + ("edit.clear", "Clear", "Löschen"), + ("edit.duplicate", "Duplicate", "Duplizieren"), + ("edit.splitLayer", "Split Layer", "Ebene teilen"), + ("edit.liftWorkArea", "Lift Work Area", "Arbeitsbereich ausheben"), + ("edit.extractWorkArea", "Extract Work Area", "Arbeitsbereich extrahieren"), + ("edit.selectAll", "Select All", "Alles auswählen"), + ("edit.deselectAll", "Deselect All", "Alles abwählen"), + ("", "Label", "Beschriftung"), + ("edit.selectLabelGroup", "Select Label Group", "Beschriftungsgruppe auswählen"), + ("edit.label", "None", "Keine"), + ("edit.label", "Red", "Rot"), + ("edit.label", "Yellow", "Gelb"), + ("edit.label", "Aqua", "Aqua"), + ("edit.label", "Pink", "Rosa"), + ("edit.label", "Lavender", "Lavendel"), + ("edit.label", "Peach", "Pfirsich"), + ("edit.label", "Sea Foam", "Seegrün"), + ("edit.label", "Blue", "Blau"), + ("edit.label", "Green", "Grün"), + ("edit.label", "Purple", "Violett"), + ("edit.label", "Orange", "Orange"), + ("edit.label", "Brown", "Braun"), + ("edit.label", "Fuchsia", "Fuchsia"), + ("edit.label", "Cyan", "Cyan"), + ("edit.label", "Sandstone", "Sandstein"), + ("edit.label", "Dark Green", "Dunkelgrün"), + ("app.settings", "Edit Label Colors...", "Beschriftungsfarben bearbeiten..."), + ("", "Select Keyframe Label Group", "Keyframe-Beschriftungsgruppe auswählen"), + ("keys.selectLabelGroup", "On Selected Layers", "Auf ausgewählten Ebenen"), + ("keys.selectLabelGroup", "On All Layers", "Auf allen Ebenen"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Sichtbare Keyframes auf ausgewählten Ebenen"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Sichtbare Keyframes auf allen Ebenen"), + ("", "Purge", "Leeren"), + ("edit.purge", "All Cache...", "Gesamter Cache..."), + ("edit.purge", "All Memory & Disk Cache...", "Gesamter Speicher- und Datenträgercache..."), + ("edit.purge", "All Memory", "Gesamter Speicher"), + ("edit.purge", "All Disk Cache...", "Gesamter Datenträgercache..."), + ("edit.purge", "All 3D Cache...", "Gesamter 3D-Cache..."), + ("edit.purgeUndo", "Undo", "Rückgängig-Historie"), + ("edit.purge", "Image Cache Memory", "Bildcache-Speicher"), + ("edit.purge", "Snapshot", "Snapshot"), + ("edit.editOriginal", "Edit Original...", "Original bearbeiten..."), + ("", "Templates", "Vorlagen"), + ("app.templates", "Render Settings...", "Renderingeinstellungen..."), + ("app.templates", "Output Module...", "Ausgabemodul..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Tastaturbefehle"), + ("", "Preferences", "Voreinstellungen"), + ("", "Composition", "Komposition"), + ("comp.new", "New Composition...", "Neue Komposition..."), + ("comp.settings", "Composition Settings...", "Kompositionseinstellungen..."), + ("comp.setPosterTime", "Set Poster Time", "Posterzeit festlegen"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Komposition auf Arbeitsbereich zurechtschneiden"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Komposition auf Interessenbereich zuschneiden"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Komposition auf Grenzen der ausgewählten Ebene(n) zuschneiden"), + ("render.addOutputModule", "Add Output Module", "Ausgabemodul hinzufügen"), + ("", "Preview", "Vorschau"), + ("playback.toggle", "Play Current Preview", "Aktuelle Vorschau wiedergeben"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Frames im Leerlauf zwischenspeichern"), + ("playback.audio", "Audio", "Audio"), + ("", "Save Frame As", "Frame speichern unter"), + ("comp.saveFrameAs", "File...", "Datei..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-Ebenen..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("render.preRender", "Pre-render...", "Vorrendern..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Aktuelle Vorschau speichern..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "In Wesentliche Grafiken öffnen"), + ("", "Responsive Design — Time", "Responsives Design – Zeit"), + ("comp.responsiveTime", "Create Intro", "Intro erstellen"), + ("comp.responsiveTime", "Create Outro", "Outro erstellen"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Geschützten Bereich aus Arbeitsbereich erstellen"), + ("comp.flowchart", "Composition Flowchart", "Kompositionsflussdiagramm"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Kompositions-Miniflussdiagramm"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "VR-Umgebung erstellen..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Cubemap extrahieren..."), + ("", "Layer", "Ebene"), + ("layer.newText", "Text", "Text"), + ("layer.newSolid", "Solid...", "Farbfläche..."), + ("layer.newLight", "Light...", "Licht..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Null-Objekt"), + ("layer.newShape", "Shape Layer", "Formebene"), + ("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."), + ("layer.new3dPrimitive", "Cube", "Würfel"), + ("layer.new3dPrimitive", "Sphere", "Kugel"), + ("layer.new3dPrimitive", "Plane", "Fläche"), + ("layer.new3dPrimitive", "Torus", "Torus"), + ("layer.new3dPrimitive", "Cone", "Kegel"), + ("layer.new3dPrimitive", "Cylinder", "Zylinder"), + ("layer.settings", "Layer Settings...", "Ebeneneinstellungen..."), + ("layer.openLayer", "Open Layer", "Ebene öffnen"), + ("layer.openSource", "Open Layer Source", "Ebenenquelle öffnen"), + ("layer.revealInFinder", "Reveal in Finder", "Im Finder einblenden"), + ("", "Mask", "Maske"), + ("layer.addMask", "New Mask", "Neue Maske"), + ("layer.mask.shape", "Mask Shape...", "Maskenform..."), + ("layer.mask.set", "Mask Feather...", "Weiche Maskenkante..."), + ("layer.mask.set", "Mask Opacity...", "Maskendeckkraft..."), + ("layer.mask.set", "Mask Expansion...", "Maskenausweitung..."), + ("layer.mask.reset", "Reset Mask", "Maske zurücksetzen"), + ("layer.mask.remove", "Remove Mask", "Maske entfernen"), + ("layer.mask.removeAll", "Remove All Masks", "Alle Masken entfernen"), + ("track.mask", "Track Mask", "Maske tracken"), + ("", "Mode", "Modus"), + ("layer.mask.mode", "None", "Ohne"), + ("layer.mask.mode", "Add", "Addieren"), + ("layer.mask.mode", "Subtract", "Subtrahieren"), + ("layer.mask.mode", "Intersect", "Überschneiden"), + ("layer.mask.mode", "Lighten", "Aufhellen"), + ("layer.mask.mode", "Darken", "Abdunkeln"), + ("layer.mask.mode", "Difference", "Differenz"), + ("layer.mask.invert", "Inverted", "Umgekehrt"), + ("layer.mask.lock", "Locked", "Gesperrt"), + ("", "Motion Blur", "Bewegungsunschärfe"), + ("layer.mask.motionBlur", "Same As Layer", "Wie Ebene"), + ("layer.mask.motionBlur", "On", "Ein"), + ("layer.mask.motionBlur", "Off", "Aus"), + ("", "Feather Falloff", "Abfall der weichen Kante"), + ("layer.mask.featherFalloff", "Smooth", "Glätten"), + ("layer.mask.featherFalloff", "Linear", "Linear"), + ("layer.mask.unlockAll", "Unlock All Masks", "Alle Masken entsperren"), + ("layer.mask.lockOthers", "Lock Other Masks", "Andere Masken sperren"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Gesperrte Masken ausblenden"), + ("", "Mask and Shape Path", "Masken- und Formpfad"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Geschlossen"), + ("path.convertToBezier", "Convert To Bezier Path", "In Bézier-Pfad konvertieren"), + ("path.setFirstVertex", "Set First Vertex", "Ersten Scheitelpunkt festlegen"), + ("path.freeTransform", "Free Transform Points", "Punkte frei transformieren"), + ("", "Quality", "Qualität"), + ("layer.quality", "Best", "Beste"), + ("layer.quality", "Draft", "Entwurf"), + ("layer.quality", "Wireframe", "Drahtgitter"), + ("layer.sampling", "Bilinear", "Bilinear"), + ("layer.sampling", "Bicubic", "Bikubisch"), + ("", "Switches", "Schalter"), + ("layer.hideOtherVideo", "Hide Other Video", "Anderes Video ausblenden"), + ("layer.showAllVideo", "Show All Video", "Gesamtes Video einblenden"), + ("layer.unlockAll", "Unlock All Layers", "Alle Ebenen entsperren"), + ("layer.expressions", "Enable Expressions", "Expressions aktivieren"), + ("layer.expressions", "Disable Expressions", "Expressions deaktivieren"), + ("layer.setSwitch", "Shy", "Schüchtern"), + ("layer.setSwitch", "Lock", "Sperren"), + ("layer.setSwitch", "Audio", "Audio"), + ("layer.setSwitch", "Video", "Video"), + ("layer.setSwitch", "Solo", "Solo"), + ("layer.setSwitch", "Effect", "Effekt"), + ("layer.setSwitch", "Collapse", "Transformierungen reduzieren"), + ("layer.setSwitch", "Motion Blur", "Bewegungsunschärfe"), + ("layer.setSwitch", "Adjustment Layer", "Einstellungsebene"), + ("", "Transform", "Transformieren"), + ("layer.transform", "Reset", "Zurücksetzen"), + ("layer.setTransform", "Anchor Point...", "Ankerpunkt..."), + ("layer.setTransform", "Position...", "Position..."), + ("layer.setTransform", "Scale...", "Skalierung..."), + ("layer.setTransform", "Orientation...", "Ausrichtung..."), + ("layer.setTransform", "Rotation...", "Drehung..."), + ("layer.setTransform", "Opacity...", "Deckkraft..."), + ("layer.transform", "Flip Horizontal", "Horizontal spiegeln"), + ("layer.transform", "Flip Vertical", "Vertikal spiegeln"), + ("layer.transform", "Center In View", "In Ansicht zentrieren"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Ankerpunkt im Ebeneninhalt zentrieren"), + ("layer.transform", "Fit to Comp", "An Komposition anpassen"), + ("layer.transform", "Fit to Comp Width", "An Kompositionsbreite anpassen"), + ("layer.transform", "Fit to Comp Height", "An Kompositionshöhe anpassen"), + ("layer.autoOrient", "Auto-Orient...", "Automatische Ausrichtung..."), + ("", "Time", "Zeit"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Zeitverzerrung aktivieren"), + ("layer.timeReverse", "Time-Reverse Layer", "Ebenenreihenfolge umkehren"), + ("layer.timeStretch", "Time Stretch...", "Zeitdehnung..."), + ("layer.freezeFrame", "Freeze Frame", "Frame fixieren"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Bei letztem Frame fixieren"), + ("layer.alignVideoToData", "Align Video to Data", "Video an Daten ausrichten"), + ("", "Frame Blending", "Frame-Überblendung"), + ("layer.frameBlending", "Off", "Aus"), + ("layer.frameBlending", "Frame Mix", "Frame-Mix"), + ("layer.frameBlending", "Pixel Motion", "Pixelbewegung"), + ("layer.setSwitch", "3D Layer", "3D-Ebene"), + ("layer.setSwitch", "Guide Layer", "Hilfsebene"), + ("layer.environment", "Environment Layer", "Umgebungsebene"), + ("", "Markers", "Marker"), + ("layer.addMarker", "Add Marker", "Marker hinzufügen"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Marker aus Quelle aktualisieren"), + ("layer.markersLock", "Lock Markers", "Marker sperren"), + ("layer.deleteAllMarkers", "Delete All Markers", "Alle Marker löschen"), + ("layer.setSwitch", "Preserve Transparency", "Transparenz beibehalten"), + ("", "Blending Mode", "Füllmethode"), + ("layer.setBlendMode", "Normal", "Normal"), + ("layer.setBlendMode", "Dissolve", "Streuen"), + ("layer.setBlendMode", "Dancing Dissolve", "Sprenkeln mit Rauschen"), + ("layer.setBlendMode", "Darken", "Abdunkeln"), + ("layer.setBlendMode", "Multiply", "Multiplizieren"), + ("layer.setBlendMode", "Color Burn", "Farbig nachbelichten"), + ("layer.setBlendMode", "Classic Color Burn", "Farbig nachbelichten - klassisch"), + ("layer.setBlendMode", "Linear Burn", "Linear nachbelichten"), + ("layer.setBlendMode", "Darker Color", "Dunklere Farbe"), + ("layer.setBlendMode", "Add", "Addieren"), + ("layer.setBlendMode", "Lighten", "Aufhellen"), + ("layer.setBlendMode", "Screen", "Bildschirm"), + ("layer.setBlendMode", "Color Dodge", "Farbig abwedeln"), + ("layer.setBlendMode", "Classic Color Dodge", "Farbig abwedeln - klassisch"), + ("layer.setBlendMode", "Linear Dodge", "Linear abwedeln"), + ("layer.setBlendMode", "Lighter Color", "Hellere Farbe"), + ("layer.setBlendMode", "Overlay", "Überlagern"), + ("layer.setBlendMode", "Soft Light", "Weiches Licht"), + ("layer.setBlendMode", "Hard Light", "Hartes Licht"), + ("layer.setBlendMode", "Linear Light", "Lineares Licht"), + ("layer.setBlendMode", "Vivid Light", "Strahlendes Licht"), + ("layer.setBlendMode", "Pin Light", "Punktuelles Licht"), + ("layer.setBlendMode", "Hard Mix", "Harte Mischung"), + ("layer.setBlendMode", "Difference", "Differenz"), + ("layer.setBlendMode", "Classic Difference", "Differenz - klassisch"), + ("layer.setBlendMode", "Exclusion", "Ausschluss"), + ("layer.setBlendMode", "Subtract", "Subtrahieren"), + ("layer.setBlendMode", "Divide", "Dividieren"), + ("layer.setBlendMode", "Hue", "Farbton"), + ("layer.setBlendMode", "Saturation", "Sättigung"), + ("layer.setBlendMode", "Color", "Farbe"), + ("layer.setBlendMode", "Luminosity", "Luminanz"), + ("layer.setBlendMode", "Stencil Alpha", "Alphaschablone"), + ("layer.setBlendMode", "Stencil Luma", "Luminanzschablone"), + ("layer.setBlendMode", "Silhouette Alpha", "Alphasilhouette"), + ("layer.setBlendMode", "Silhouette Luma", "Luminanzsilhouette"), + ("layer.setBlendMode", "Alpha Add", "Alpha addieren"), + ("layer.setBlendMode", "Luminescent Premul", "Luminanz integrieren"), + ("layer.setBlendMode", "Next Blending Mode", "Nächste Füllmethode"), + ("layer.setBlendMode", "Previous Blending Mode", "Vorherige Füllmethode"), + ("", "Track Matte", "Spurmaske"), + ("layer.trackMatte", "No Track Matte", "Keine Spurmaske"), + ("layer.trackMatte", "Alpha Matte", "Alphamaske"), + ("layer.trackMatte", "Alpha Inverted Matte", "Umgekehrte Alphamaske"), + ("layer.trackMatte", "Luma Matte", "Luminanzmaske"), + ("layer.trackMatte", "Luma Inverted Matte", "Umgekehrte Luminanzmaske"), + ("layer.trackMatte", "Matte with Layer Above", "Spurmaske mit Ebene darüber"), + ("layer.trackMatte", "Matte with Layer Below", "Spurmaske mit Ebene darunter"), + ("", "Layer Styles", "Ebenenstile"), + ("layer.style.options", "Layer Style Options...", "Ebenenstiloptionen..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "In editierbare Formate umwandeln"), + ("layer.style.showAll", "Show All", "Alle anzeigen"), + ("layer.style.removeAll", "Remove All", "Alle entfernen"), + ("layer.style.dropShadow", "Drop Shadow", "Schlagschatten"), + ("layer.style.innerShadow", "Inner Shadow", "Schatten nach innen"), + ("layer.style.outerGlow", "Outer Glow", "Schein nach außen"), + ("layer.style.innerGlow", "Inner Glow", "Schein nach innen"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Abgeflachte Kante und Relief"), + ("layer.style.satin", "Satin", "Glanz"), + ("layer.style.colorOverlay", "Color Overlay", "Farbüberlagerung"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Verlaufsüberlagerung"), + ("layer.style.stroke", "Stroke", "Strich"), + ("path.groupShapes", "Group Shapes", "Formen gruppieren"), + ("path.ungroupShapes", "Ungroup Shapes", "Gruppierung von Formen aufheben"), + ("", "Arrange", "Anordnen"), + ("layer.arrange", "Bring Layer to Front", "Ebene ganz nach vorn"), + ("layer.arrange", "Bring Layer Forward", "Ebene nach vorne"), + ("layer.arrange", "Send Layer Backward", "Ebene nach hinten"), + ("layer.arrange", "Send Layer to Back", "Ebene ganz nach hinten"), + ("", "Reveal", "Anzeigen"), + ("layer.revealSource", "Reveal Layer Source in Project", "Ebenenquelle im Projekt anzeigen"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Ebene im Projekt-Flussdiagramm anzeigen"), + ("comp.revealInProject", "Reveal Composition in Project", "Komposition im Projekt anzeigen"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Ausdrucksfehler anzeigen"), + ("", "Create", "Erstellen"), + ("layer.create", "Convert to Editable Text", "In editierbaren Text umwandeln"), + ("layer.create", "Create Shapes from Text", "Formen aus Text erstellen"), + ("layer.create", "Create Masks from Text", "Masken aus Text erstellen"), + ("layer.create", "Create Shapes from Vector Layer", "Formen aus Vektorebene erstellen"), + ("layer.create", "Create Keyframes from Data", "Keyframes aus Daten erstellen"), + ("layer.create", "Null Controllers for Positional Points", "Null-Steuerungen für Positionspunkte"), + ("layer.create", "Null Controllers for Path Points", "Null-Steuerungen für Pfadpunkte"), + ("layer.create", "Nulls Following Path Points", "Nullebenen, die Pfadpunkten folgen"), + ("layer.create", "Null Tracing Along Path", "Nullebene, die dem Pfad folgt"), + ("layer.create", "Create 3D Layer Instance", "3D-Ebeneninstanz erstellen"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Kamera aus 3D-Ansicht erstellen"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Stereo-3D-Rig erstellen"), + ("camera.orbitNull", "Create Orbit Null", "Orbit-Nullebene erstellen"), + ("camera.fromModel", "Create Cameras from 3D Model", "Kameras aus 3D-Modell erstellen"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Fokusdistanz an Point of Interest binden"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Fokusdistanz an Ebene binden"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Fokusdistanz auf Ebene festlegen"), + ("layer.cameraSettings", "Camera Settings...", "Kameraeinstellungen..."), + ("view.reset3DView", "Reset 3D View", "3D-Ansicht zurücksetzen"), + ("", "Light", "Licht"), + ("light.fromModel", "Create Lights from 3D Model", "Lichter aus 3D-Modell erstellen"), + ("light.controlWithCamera", "Control Light with Camera", "Licht mit Kamera steuern"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Hintergrundebene für Umgebungslicht erstellen"), + ("", "Material", "Material"), + ("material.revealSource", "Reveal Material Source in Project", "Materialquelle im Projekt anzeigen"), + ("material.reset", "Reset Material", "Material zurücksetzen"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Material duplizieren und zuweisen"), + ("layer.autoTrace", "Auto-trace...", "Automatisch nachzeichnen..."), + ("layer.precompose", "Pre-compose...", "Vorkomponieren..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Szenenbearbeitungs-Erkennung..."), + ("", "Effect", "Effekt"), + ("window.panel", "Effect Controls", "Effektsteuerungen"), + ("effect.applyLast", "Last Effect", "Letzter Effekt"), + ("effect.removeAll", "Remove All", "Alle entfernen"), + ("effect.manage", "Manage Effects...", "Effekte verwalten..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Effekt-Plug-in laden..."), + ("", "Animation", "Animation"), + ("anim.savePreset", "Save Animation Preset...", "Animationsvorgabe speichern..."), + ("anim.applyPreset", "Apply Animation Preset...", "Animationsvorgabe anwenden..."), + ("", "Recent Animation Presets", "Zuletzt verwendete Animationsvorgaben"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Zuletzt verwendete Vorgaben löschen"), + ("anim.browsePresets", "Browse Presets...", "Vorgaben durchsuchen..."), + ("", "Text Animation Presets", "Textanimationsvorgaben"), + ("layer.applyTextPreset", "Typewriter", "Schreibmaschine"), + ("layer.applyTextPreset", "Fade Up Characters", "Zeichen einblenden"), + ("layer.applyTextPreset", "Bounce In Words", "Wörter springen ein"), + ("layer.applyTextPreset", "Tracking In", "Laufweite verringern"), + ("layer.applyTextPreset", "Scramble", "Verwürfeln"), + ("layer.applyTextPreset", "Blur In", "Aus Unschärfe einblenden"), + ("layer.applyTextPreset", "Jitter", "Zittern"), + ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), + ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), + ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), + ("", "Keyframe Assistant", "Keyframe-Assistent"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Audio in Keyframes konvertieren"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Ausdruck in Keyframes konvertieren"), + ("keys.easyEase", "Easy Ease", "Easy Ease"), + ("keys.easyEaseIn", "Easy Ease In", "Easy Ease In"), + ("keys.easyEaseOut", "Easy Ease Out", "Easy Ease Out"), + ("keys.exponentialScale", "Exponential Scale", "Exponentielle Skalierung"), + ("keys.rpfCameraImport", "RPF Camera Import", "RPF-Kameraimport"), + ("layer.sequence", "Sequence Layers...", "Ebenen sequenzieren..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Keyframes zeitlich umkehren"), + ("", "Animate Text", "Text animieren"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "3D pro Zeichen aktivieren"), + ("layer.addTextAnimator", "Anchor Point", "Ankerpunkt"), + ("layer.addTextAnimator", "Position", "Position"), + ("layer.addTextAnimator", "Scale", "Skalierung"), + ("layer.addTextAnimator", "Skew", "Neigung"), + ("layer.addTextAnimator", "Rotation", "Drehung"), + ("layer.addTextAnimator", "Opacity", "Deckkraft"), + ("layer.addTextAnimator", "All Transform Properties", "Alle Transformationseigenschaften"), + ("", "Fill Color", "Füllfarbe"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Farbton"), + ("layer.addTextAnimator", "Saturation", "Sättigung"), + ("layer.addTextAnimator", "Brightness", "Helligkeit"), + ("", "Stroke Color", "Strichfarbe"), + ("layer.addTextAnimator", "Stroke Width", "Strichstärke"), + ("layer.addTextAnimator", "Tracking", "Laufweite"), + ("layer.addTextAnimator", "Line Anchor", "Zeilenanker"), + ("layer.addTextAnimator", "Line Spacing", "Zeilenabstand"), + ("layer.addTextAnimator", "Character Offset", "Zeichenversatz"), + ("layer.addTextAnimator", "Character Value", "Zeichenwert"), + ("layer.addTextAnimator", "Blur", "Unschärfe"), + ("text.animatorFontAxes", "Variable Font Axes", "Achsen der variablen Schriftart"), + ("", "Add Text Selector", "Textselektor hinzufügen"), + ("text.addSelector", "Range", "Bereich"), + ("text.addSelector", "Wiggly", "Wackeln"), + ("text.addSelector", "Expression", "Ausdruck"), + ("text.removeAllAnimators", "Remove All Text Animators", "Alle Text-Animatoren entfernen"), + ("prop.setExpression", "Add Expression", "Ausdruck hinzufügen"), + ("essential.addProperty", "Add Property to Essential Graphics", "Eigenschaft zu Essential-Grafik hinzufügen"), + ("prop.separateDimensions", "Separate Dimensions", "Dimensionen trennen"), + ("track.camera", "Track Camera", "Kamera verfolgen"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp-Stabilisator VFX"), + ("track.motion", "Track Motion", "Bewegung verfolgen"), + ("track.stabilize", "Stabilize Motion", "Bewegung stabilisieren"), + ("track.property", "Track this Property", "Diese Eigenschaft verfolgen"), + ("anim.reveal", "Reveal Properties with Keyframes", "Eigenschaften mit Keyframes anzeigen"), + ("anim.reveal", "Reveal Properties with Animation", "Eigenschaften mit Animation anzeigen"), + ("anim.reveal", "Reveal All Modified Properties", "Alle geänderten Eigenschaften anzeigen"), + ("", "View", "Ansicht"), + ("view.newViewer", "New Viewer", "Neuer Viewer"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Mit neuem gesperrten Viewer teilen"), + ("view.zoomIn", "Zoom In", "Vergrößern"), + ("view.zoomOut", "Zoom Out", "Verkleinern"), + ("", "Resolution", "Auflösung"), + ("view.res.full", "Full", "Voll"), + ("view.res.half", "Half", "Halb"), + ("view.res.third", "Third", "Drittel"), + ("view.res.quarter", "Quarter", "Viertel"), + ("view.res.custom", "Custom...", "Benutzerdefiniert..."), + ("view.displayColorManagement", "Use Display Color Management", "Farbverwaltung des Displays verwenden"), + ("", "Simulate Output", "Ausgabe simulieren"), + ("view.simulateOutput", "No Output Simulation", "Keine Ausgabesimulation"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Legacy-Macintosh-RGB (Gamma 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Internet-Standard-RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Linear (Gamma 1.0)"), + ("view.customRgb", "My Custom RGB...", "Mein eigenes RGB..."), + ("view.simulateOutput", "Custom...", "Benutzerdefiniert..."), + ("view.rulers", "Show Rulers", "Lineale anzeigen"), + ("", "Panel Background Color", "Hintergrundfarbe des Bedienfelds"), + ("view.panelBackground", "Black", "Schwarz"), + ("view.panelBackground", "Dark Gray", "Dunkelgrau"), + ("view.panelBackground", "Medium Gray (Default)", "Mittelgrau (Standard)"), + ("view.panelBackground", "Light Gray", "Hellgrau"), + ("view.panelBackground", "White", "Weiß"), + ("view.panelBackground", "Custom", "Benutzerdefiniert"), + ("view.panelBackground", "Select Custom Background Color...", "Benutzerdefinierte Hintergrundfarbe auswählen..."), + ("view.guides", "Show Guides", "Hilfslinien anzeigen"), + ("view.snapToGuides", "Snap to Guides", "An Hilfslinien einrasten"), + ("view.lockGuides", "Lock Guides", "Hilfslinien sperren"), + ("view.addGuide", "Add Guide...", "Hilfslinie hinzufügen..."), + ("view.clearGuides", "Clear Guides", "Hilfslinien entfernen"), + ("view.importGuides", "Import Guides...", "Hilfslinien importieren..."), + ("view.exportGuides", "Export Guides...", "Hilfslinien exportieren..."), + ("view.grid", "Show Grid", "Raster anzeigen"), + ("view.snapToGrid", "Snap to Grid", "Am Raster einrasten"), + ("view.snapping", "Snapping", "Einrasten"), + ("view.options", "View Options...", "Ansichtsoptionen..."), + ("view.layerControls", "Show Layer Controls", "Ebenensteuerelemente anzeigen"), + ("", "Switch View Layout", "Ansichtslayout wechseln"), + ("view.layout", "1 View", "1 Ansicht"), + ("view.layout", "2 Views", "2 Ansichten"), + ("view.layout", "4 Views", "4 Ansichten"), + ("view.shareViewOptions", "Share View Options", "Ansichtsoptionen teilen"), + ("", "Switch 3D View", "3D-Ansicht wechseln"), + ("view.3d.activeCamera", "Active Camera", "Aktive Kamera"), + ("view.3d.default", "Default", "Standard"), + ("view.3d.front", "Front", "Vorne"), + ("view.3d.left", "Left", "Links"), + ("view.3d.top", "Top", "Oben"), + ("view.3d.back", "Back", "Hinten"), + ("view.3d.right", "Right", "Rechts"), + ("view.3d.bottom", "Bottom", "Unten"), + ("view.3d.custom1", "Custom View 1", "Benutzeransicht 1"), + ("view.3d.custom2", "Custom View 2", "Benutzeransicht 2"), + ("view.3d.custom3", "Custom View 3", "Benutzeransicht 3"), + ("", "Assign Shortcut to 3D View", "Tastenkürzel für 3D-Ansicht zuweisen"), + ("view.3d.last", "Switch to Last 3D View", "Zur letzten 3D-Ansicht wechseln"), + ("view.lookAtSelected", "Look at Selected Layers", "Ausgewählte Ebenen fokussieren"), + ("view.lookAtAll", "Look at All Layers", "Alle Ebenen fokussieren"), + ("time.set", "Go to Time...", "Gehe zu Zeit..."), + ("view.fullScreen", "Enter Full Screen", "Vollbildmodus aktivieren"), + ("", "Window", "Fenster"), + ("", "Workspace", "Arbeitsfläche"), + ("window.workspace", "Default", "Standard"), + ("window.workspace", "Review", "Überprüfen"), + ("window.workspace", "Learn", "Lernen"), + ("window.workspace", "Small Screen", "Kleiner Bildschirm"), + ("window.workspace", "Standard", "Standard"), + ("window.workspace", "All Panels", "Alle Bedienfelder"), + ("window.workspace", "Animation", "Animation"), + ("window.workspace", "Color", "Farbe"), + ("window.workspace", "Effects", "Effekte"), + ("window.workspace", "Essential Graphics", "Essential-Grafik"), + ("window.workspace", "Minimal", "Minimal"), + ("window.workspace", "Motion Tracking", "Bewegungsverfolgung"), + ("window.workspace", "Paint", "Malen"), + ("window.workspace", "Text", "Text"), + ("window.workspace", "Undocked Panels", "Abgedockte Bedienfelder"), + ("window.resetWorkspace", "Reset to Saved Layout", "Auf gespeichertes Layout zurücksetzen"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Änderungen an dieser Arbeitsfläche speichern"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Als neue Arbeitsfläche speichern..."), + ("window.editWorkspaces", "Edit Workspaces...", "Arbeitsflächen bearbeiten..."), + ("", "Assign Shortcut to Workspace", "Tastenkürzel für Arbeitsfläche zuweisen"), + ("window.panel", "Align", "Ausrichten"), + ("window.panel", "Audio", "Audio"), + ("window.panel", "Brushes", "Pinsel"), + ("window.panel", "Character", "Zeichen"), + ("window.panel", "Content-Aware Fill", "Inhaltsbasiertes Ausfüllen"), + ("window.panel", "Effects & Presets", "Effekte und Presets"), + ("window.panel", "Essential Graphics", "Essential-Grafik"), + ("window.panel", "Info", "Info"), + ("help.inAppTutorials", "Learn", "Lernen"), + ("window.panel", "Lumetri Scopes", "Lumetri-Scopes"), + ("window.panel", "Mask Interpolation", "Maskeninterpolation"), + ("window.panel", "Media Browser", "Medienbrowser"), + ("window.panel", "Metadata", "Metadaten"), + ("window.panel", "Motion Sketch", "Bewegungsskizze"), + ("window.panel", "Paint", "Malen"), + ("window.panel", "Paragraph", "Absatz"), + ("window.panel", "Preview", "Vorschau"), + ("window.panel", "Progress", "Fortschritt"), + ("window.panel", "Properties", "Eigenschaften"), + ("window.panel", "Script Console", "Skriptkonsole"), + ("window.panel", "Smoother", "Smoother"), + ("window.panel", "Tools", "Werkzeuge"), + ("window.panel", "Tracker", "Tracker"), + ("window.panel", "Wiggler", "Wiggler"), + ("window.panel", "Composition", "Komposition"), + ("window.panel", "Flowchart", "Flussdiagramm"), + ("window.panel", "Footage", "Footage"), + ("window.panel", "Layer", "Ebene"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Render-Warteschlange"), + ("window.panel", "Timeline", "Zeitleiste"), + ("window.panel", "Create Nulls From Paths", "Null-Objekte aus Pfaden erstellen"), + ("window.panel", "VR Comp Editor", "VR-Kompositionseditor"), + ("", "Help", "Hilfe"), + ("help.docs", "EffectCraft Help...", "EffectCraft-Hilfe..."), + ("help.docs", "Scripting Help...", "Hilfe zu Skripts..."), + ("help.docs", "Expression Reference...", "Ausdrucksreferenz..."), + ("help.docs", "Effect Reference...", "Effektreferenz..."), + ("anim.browsePresets", "Animation Presets...", "Animations-Presets..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Tastenkürzel..."), + ("help.inAppTutorials", "In-App Tutorials...", "In-App-Tutorials..."), + ("help.onlineTutorials", "Online Tutorials...", "Online-Tutorials..."), + ("help.systemReport", "System Compatibility Report...", "Bericht zur Systemkompatibilität..."), + ("help.enableLogging", "Enable Logging", "Protokollierung aktivieren"), + ("help.revealLogFile", "Reveal Logging File", "Protokolldatei anzeigen"), + ("help.discord", "Join the ArtCraft Discord...", "ArtCraft-Discord beitreten..."), + ("help.reportIssue", "Provide Feedback...", "Feedback senden..."), + ("help.website", "ArtCraft Website", "ArtCraft-Website"), + ("help.appPage", "EffectCraft Home Page", "EffectCraft-Startseite"), + ("help.github", "EffectCraft on GitHub", "EffectCraft auf GitHub"), + ("file.openDemoProject", "Open Demo Project", "Demoprojekt öffnen"), +]; + +// <<< generated i18n catalogs + +/// Czech (cs): original phrase translations of every fixed menu entry. +const CZECH: &[(&str, &str, &str)] = &[ + ("", "Front", "Přední strana"), + ("", "Bevel", "Zkosení"), + ("", "Side", "Boční strana"), + ("", "Back", "Zadní strana"), + ("layer.addTextAnimator", "Color", "Barva"), + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "O aplikaci EffectCraft..."), + ("", "Settings...", "Nastavení..."), + ("app.settings", "General...", "Obecné..."), + ("app.settings", "Startup & Repair...", "Spuštění a oprava..."), + ("app.settings", "Project...", "Projekt..."), + ("app.settings", "Composition...", "Kompozice..."), + ("app.settings", "Previews...", "Náhledy..."), + ("app.settings", "Appearance...", "Vzhled..."), + ("app.settings", "Grids & Guides...", "Mřížky a vodítka..."), + ("app.settings", "Labels...", "Štítky..."), + ("app.settings", "Type...", "Text..."), + ("app.settings", "Import...", "Import..."), + ("app.settings", "Export...", "Export..."), + ("app.settings", "Audio...", "Zvuk..."), + ("app.settings", "Disk...", "Disk..."), + ("app.settings", "Memory & CPU...", "Paměť a CPU..."), + ("app.settings", "Video...", "Video..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "Skripty a výrazy..."), + ("app.hide", "Hide EffectCraft", "Skrýt EffectCraft"), + ("app.hideOthers", "Hide Others", "Skrýt ostatní"), + ("app.showAll", "Show All", "Zobrazit vše"), + ("app.quit", "Quit EffectCraft", "Ukončit EffectCraft"), + ("", "File", "Soubor"), + ("", "New", "Nový"), + ("file.newProject", "New Project", "Nový projekt"), + ("file.newFromTemplate", "New Project from Template...", "Nový projekt ze šablony..."), + ("project.newFolder", "New Folder", "Nová složka"), + ("file.open", "Open Project...", "Otevřít projekt..."), + ("", "Open Recent", "Otevřít nedávné"), + ("file.clearRecent", "Clear Recent Projects", "Vymazat nedávné projekty"), + ("file.close", "Close", "Zavřít"), + ("file.closeProject", "Close Project", "Zavřít projekt"), + ("file.save", "Save", "Uložit"), + ("", "Save As", "Uložit jako"), + ("file.saveAs", "Save As...", "Uložit jako..."), + ("file.saveCopy", "Save a Copy...", "Uložit kopii..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "Uložit kopii jako XML..."), + ("templates.saveAs", "Save as Template...", "Uložit jako šablonu..."), + ("file.incrementAndSave", "Increment and Save", "Zvýšit číslo verze a uložit"), + ("file.revert", "Revert", "Vrátit k uložené verzi"), + ("", "Import", "Importovat"), + ("file.import", "File...", "Soubor..."), + ("file.importMultiple", "Multiple Files...", "Více souborů..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."), + ("file.importSolid", "Solid...", "Plná vrstva..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Úběžný bod (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "Šablona základní grafiky..."), + ("", "Import Recent Footage", "Importovat nedávný záznam"), + ("file.clearRecentFootage", "Clear Recent Footage", "Vymazat nedávné záznamy"), + ("", "Export", "Exportovat"), + ("renderQueue.add", "Add to Render Queue", "Přidat do fronty vykreslování"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "Šablona základní grafiky..."), + ("app.find", "Find", "Najít"), + ("layer.addItem", "Add Footage to Comp", "Přidat záznam do kompozice"), + ("file.newCompFromSelection", "New Comp from Selection...", "Nová kompozice z výběru..."), + ("", "Dependencies", "Závislosti"), + ("file.collectFiles", "Collect Files...", "Shromáždit soubory..."), + ("file.consolidateFootage", "Consolidate All Footage", "Sloučit duplicitní záznamy"), + ("file.removeUnusedFootage", "Remove Unused Footage", "Odstranit nepoužité záznamy"), + ("file.reduceProject", "Reduce Project", "Zredukovat projekt"), + ("file.findMissing", "Find Missing Effects", "Najít chybějící efekty"), + ("file.findMissing", "Find Missing Fonts", "Najít chybějící písma"), + ("file.findMissing", "Find Missing Footage", "Najít chybějící záznamy"), + ("file.relinkFootage", "Relink Missing Footage...", "Znovu připojit chybějící záznamy..."), + ("file.watchFolder", "Watch Folder...", "Sledovat složku..."), + ("", "Scripts", "Skripty"), + ("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."), + ("file.runScript", "Run Script File...", "Spustit soubor skriptu..."), + ("", "Create Proxy", "Vytvořit zástupný soubor"), + ("file.createProxy", "Still...", "Statický snímek..."), + ("file.createProxy", "Movie...", "Video..."), + ("", "Set Proxy", "Nastavit zástupný soubor"), + ("file.setProxy", "File...", "Soubor..."), + ("file.setProxyNone", "None", "Žádný"), + ("", "Interpret Footage", "Interpretovat záznam"), + ("file.interpretFootage", "Main...", "Hlavní..."), + ("file.interpretProxy", "Proxy...", "Zástupný..."), + ("file.rememberInterpretation", "Remember Interpretation", "Zapamatovat interpretaci"), + ("file.applyInterpretation", "Apply Interpretation", "Použít interpretaci"), + ("", "Replace Footage", "Nahradit záznam"), + ("file.replaceFootage", "File...", "Soubor..."), + ("file.replaceWithPlaceholder", "Placeholder...", "Zástupný záznam..."), + ("file.replaceWithSolid", "Solid...", "Plná vrstva..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "Kompozicí s vrstvami"), + ("file.reloadFootage", "Reload Footage", "Znovu načíst záznam"), + ("file.revealInFinder", "Reveal in Finder", "Zobrazit ve Finderu"), + ("file.projectSettings", "Project Settings...", "Nastavení projektu..."), + ("app.quit", "Exit", "Ukončit"), + ("", "Edit", "Úpravy"), + ("edit.undo", "Undo", "Zpět"), + ("edit.redo", "Redo", "Znovu"), + ("", "History", "Historie"), + ("app.commandPalette", "Quick Apply...", "Rychle použít..."), + ("edit.cut", "Cut", "Vyjmout"), + ("edit.copy", "Copy", "Kopírovat"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "Kopírovat s odkazy na vlastnosti"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "Kopírovat s relativními odkazy na vlastnosti"), + ("edit.copyExpressionOnly", "Copy Expression Only", "Kopírovat pouze výraz"), + ("edit.paste", "Paste", "Vložit"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "Vložit klíčové snímky v opačném pořadí"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "Vložit text a přizpůsobit formátování"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "Vložit pouze formátování textu"), + ("edit.clear", "Clear", "Vymazat"), + ("edit.duplicate", "Duplicate", "Duplikovat"), + ("edit.splitLayer", "Split Layer", "Rozdělit vrstvu"), + ("edit.liftWorkArea", "Lift Work Area", "Vyjmout pracovní oblast bez posunu"), + ("edit.extractWorkArea", "Extract Work Area", "Vyjmout pracovní oblast s posunem"), + ("edit.selectAll", "Select All", "Vybrat vše"), + ("edit.deselectAll", "Deselect All", "Zrušit výběr"), + ("", "Label", "Štítek"), + ("edit.selectLabelGroup", "Select Label Group", "Vybrat skupinu podle štítku"), + ("edit.label", "None", "Žádný"), + ("edit.label", "Red", "Červený"), + ("edit.label", "Yellow", "Žlutý"), + ("edit.label", "Aqua", "Akvamarínový"), + ("edit.label", "Pink", "Růžový"), + ("edit.label", "Lavender", "Levandulový"), + ("edit.label", "Peach", "Broskvový"), + ("edit.label", "Sea Foam", "Mořská pěna"), + ("edit.label", "Blue", "Modrý"), + ("edit.label", "Green", "Zelený"), + ("edit.label", "Purple", "Fialový"), + ("edit.label", "Orange", "Oranžový"), + ("edit.label", "Brown", "Hnědý"), + ("edit.label", "Fuchsia", "Fuchsiový"), + ("edit.label", "Cyan", "Azurový"), + ("edit.label", "Sandstone", "Pískovcový"), + ("edit.label", "Dark Green", "Tmavě zelený"), + ("app.settings", "Edit Label Colors...", "Upravit barvy štítků..."), + ("", "Select Keyframe Label Group", "Vybrat skupinu podle štítku klíčových snímků"), + ("keys.selectLabelGroup", "On Selected Layers", "Na vybraných vrstvách"), + ("keys.selectLabelGroup", "On All Layers", "Na všech vrstvách"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "Viditelné klíčové snímky na vybraných vrstvách"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "Viditelné klíčové snímky na všech vrstvách"), + ("", "Purge", "Vyprázdnit"), + ("edit.purge", "All Cache...", "Celou mezipaměť..."), + ("edit.purge", "All Memory & Disk Cache...", "Celou paměťovou a diskovou mezipaměť..."), + ("edit.purge", "All Memory", "Veškerou paměť"), + ("edit.purge", "All Disk Cache...", "Celou diskovou mezipaměť..."), + ("edit.purge", "All 3D Cache...", "Celou 3D mezipaměť..."), + ("edit.purgeUndo", "Undo", "Historii vracení změn"), + ("edit.purge", "Image Cache Memory", "Paměť obrazové mezipaměti"), + ("edit.purge", "Snapshot", "Snímek náhledu"), + ("edit.editOriginal", "Edit Original...", "Upravit originál..."), + ("", "Templates", "Šablony"), + ("app.templates", "Render Settings...", "Nastavení vykreslování..."), + ("app.templates", "Output Module...", "Výstupní modul..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "Klávesové zkratky"), + ("", "Preferences", "Předvolby"), + ("", "Composition", "Kompozice"), + ("comp.new", "New Composition...", "Nová kompozice..."), + ("comp.settings", "Composition Settings...", "Nastavení kompozice..."), + ("comp.setPosterTime", "Set Poster Time", "Nastavit čas reprezentativního snímku"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "Oříznout kompozici na pracovní oblast"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "Oříznout kompozici na oblast zájmu"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "Oříznout kompozici na hranice vybrané vrstvy (vrstev)"), + ("render.addOutputModule", "Add Output Module", "Přidat výstupní modul"), + ("", "Preview", "Náhled"), + ("playback.toggle", "Play Current Preview", "Přehrát aktuální náhled"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "Ukládat snímky do mezipaměti při nečinnosti"), + ("playback.audio", "Audio", "Zvuk"), + ("", "Save Frame As", "Uložit snímek jako"), + ("comp.saveFrameAs", "File...", "Soubor..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Vrstvy Photoshopu..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "Kopírovat snímek do schránky"), + ("render.preRender", "Pre-render...", "Předvykreslit..."), + ("render.saveCurrentPreview", "Save Current Preview...", "Uložit aktuální náhled..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otevřít v základní grafice"), + ("", "Responsive Design — Time", "Responzivní návrh — čas"), + ("comp.responsiveTime", "Create Intro", "Vytvořit úvod"), + ("comp.responsiveTime", "Create Outro", "Vytvořit závěr"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "Vytvořit chráněnou oblast z pracovní oblasti"), + ("comp.flowchart", "Composition Flowchart", "Vývojový diagram kompozice"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "Malý vývojový diagram kompozice"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "Vytvořit prostředí VR..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "Extrahovat krychlovou mapu..."), + ("", "Layer", "Vrstva"), + ("layer.newText", "Text", "Text"), + ("layer.newSolid", "Solid...", "Plná vrstva..."), + ("layer.newLight", "Light...", "Světlo..."), + ("layer.newCamera", "Camera...", "Kamera..."), + ("layer.newNull", "Null Object", "Prázdný objekt"), + ("layer.newShape", "Shape Layer", "Vrstva tvaru"), + ("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."), + ("layer.new3dPrimitive", "Cube", "Krychle"), + ("layer.new3dPrimitive", "Sphere", "Koule"), + ("layer.new3dPrimitive", "Plane", "Rovina"), + ("layer.new3dPrimitive", "Torus", "Prstenec"), + ("layer.new3dPrimitive", "Cone", "Kužel"), + ("layer.new3dPrimitive", "Cylinder", "Válec"), + ("layer.settings", "Layer Settings...", "Nastavení vrstvy..."), + ("layer.openLayer", "Open Layer", "Otevřít vrstvu"), + ("layer.openSource", "Open Layer Source", "Otevřít zdroj vrstvy"), + ("layer.revealInFinder", "Reveal in Finder", "Zobrazit ve Finderu"), + ("", "Mask", "Maska"), + ("layer.addMask", "New Mask", "Nová maska"), + ("layer.mask.shape", "Mask Shape...", "Tvar masky..."), + ("layer.mask.set", "Mask Feather...", "Prolnutí okrajů masky..."), + ("layer.mask.set", "Mask Opacity...", "Krytí masky..."), + ("layer.mask.set", "Mask Expansion...", "Rozšíření masky..."), + ("layer.mask.reset", "Reset Mask", "Obnovit masku"), + ("layer.mask.remove", "Remove Mask", "Odstranit masku"), + ("layer.mask.removeAll", "Remove All Masks", "Odstranit všechny masky"), + ("track.mask", "Track Mask", "Sledovat masku"), + ("", "Mode", "Režim"), + ("layer.mask.mode", "None", "Žádný"), + ("layer.mask.mode", "Add", "Přičíst"), + ("layer.mask.mode", "Subtract", "Odečíst"), + ("layer.mask.mode", "Intersect", "Průnik"), + ("layer.mask.mode", "Lighten", "Zesvětlit"), + ("layer.mask.mode", "Darken", "Ztmavit"), + ("layer.mask.mode", "Difference", "Rozdíl"), + ("layer.mask.invert", "Inverted", "Invertovaná"), + ("layer.mask.lock", "Locked", "Zamknutá"), + ("", "Motion Blur", "Rozostření pohybem"), + ("layer.mask.motionBlur", "Same As Layer", "Stejné jako u vrstvy"), + ("layer.mask.motionBlur", "On", "Zapnuto"), + ("layer.mask.motionBlur", "Off", "Vypnuto"), + ("", "Feather Falloff", "Průběh prolnutí okrajů"), + ("layer.mask.featherFalloff", "Smooth", "Plynulý"), + ("layer.mask.featherFalloff", "Linear", "Lineární"), + ("layer.mask.unlockAll", "Unlock All Masks", "Odemknout všechny masky"), + ("layer.mask.lockOthers", "Lock Other Masks", "Zamknout ostatní masky"), + ("layer.mask.hideLocked", "Hide Locked Masks", "Skrýt zamknuté masky"), + ("", "Mask and Shape Path", "Cesta masky a tvaru"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "Uzavřená"), + ("path.convertToBezier", "Convert To Bezier Path", "Převést na Bézierovu cestu"), + ("path.setFirstVertex", "Set First Vertex", "Nastavit první vrchol"), + ("path.freeTransform", "Free Transform Points", "Volně transformovat body"), + ("", "Quality", "Kvalita"), + ("layer.quality", "Best", "Nejlepší"), + ("layer.quality", "Draft", "Pracovní"), + ("layer.quality", "Wireframe", "Drátový model"), + ("layer.sampling", "Bilinear", "Bilineární"), + ("layer.sampling", "Bicubic", "Bikubické"), + ("", "Switches", "Přepínače"), + ("layer.hideOtherVideo", "Hide Other Video", "Skrýt ostatní video"), + ("layer.showAllVideo", "Show All Video", "Zobrazit veškeré video"), + ("layer.unlockAll", "Unlock All Layers", "Odemknout všechny vrstvy"), + ("layer.expressions", "Enable Expressions", "Povolit výrazy"), + ("layer.expressions", "Disable Expressions", "Zakázat výrazy"), + ("layer.setSwitch", "Shy", "Skrýt z časové osy"), + ("layer.setSwitch", "Lock", "Zamknout"), + ("layer.setSwitch", "Audio", "Zvuk"), + ("layer.setSwitch", "Video", "Video"), + ("layer.setSwitch", "Solo", "Sólo"), + ("layer.setSwitch", "Effect", "Efekt"), + ("layer.setSwitch", "Collapse", "Sbalit transformace"), + ("layer.setSwitch", "Motion Blur", "Rozostření pohybem"), + ("layer.setSwitch", "Adjustment Layer", "Vrstva úprav"), + ("", "Transform", "Transformace"), + ("layer.transform", "Reset", "Obnovit"), + ("layer.setTransform", "Anchor Point...", "Kotevní bod..."), + ("layer.setTransform", "Position...", "Pozice..."), + ("layer.setTransform", "Scale...", "Měřítko..."), + ("layer.setTransform", "Orientation...", "Orientace..."), + ("layer.setTransform", "Rotation...", "Otočení..."), + ("layer.setTransform", "Opacity...", "Krytí..."), + ("layer.transform", "Flip Horizontal", "Převrátit vodorovně"), + ("layer.transform", "Flip Vertical", "Převrátit svisle"), + ("layer.transform", "Center In View", "Vystředit v zobrazení"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "Vystředit kotevní bod v obsahu vrstvy"), + ("layer.transform", "Fit to Comp", "Přizpůsobit kompozici"), + ("layer.transform", "Fit to Comp Width", "Přizpůsobit šířce kompozice"), + ("layer.transform", "Fit to Comp Height", "Přizpůsobit výšce kompozice"), + ("layer.autoOrient", "Auto-Orient...", "Automaticky orientovat..."), + ("", "Time", "Čas"), + ("layer.enableTimeRemap", "Enable Time Remapping", "Povolit přemapování času"), + ("layer.timeReverse", "Time-Reverse Layer", "Obrátit čas vrstvy"), + ("layer.timeStretch", "Time Stretch...", "Roztažení času..."), + ("layer.freezeFrame", "Freeze Frame", "Zmrazit snímek"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "Zmrazit na posledním snímku"), + ("layer.alignVideoToData", "Align Video to Data", "Zarovnat video podle dat"), + ("", "Frame Blending", "Prolnutí snímků"), + ("layer.frameBlending", "Off", "Vypnuto"), + ("layer.frameBlending", "Frame Mix", "Smíchání snímků"), + ("layer.frameBlending", "Pixel Motion", "Pohyb pixelů"), + ("layer.setSwitch", "3D Layer", "3D vrstva"), + ("layer.setSwitch", "Guide Layer", "Pomocná vrstva"), + ("layer.environment", "Environment Layer", "Vrstva prostředí"), + ("", "Markers", "Značky"), + ("layer.addMarker", "Add Marker", "Přidat značku"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "Aktualizovat značky ze zdroje"), + ("layer.markersLock", "Lock Markers", "Zamknout značky"), + ("layer.deleteAllMarkers", "Delete All Markers", "Odstranit všechny značky"), + ("layer.setSwitch", "Preserve Transparency", "Zachovat průhlednost"), + ("", "Blending Mode", "Režim prolnutí"), + ("layer.setBlendMode", "Normal", "Normální"), + ("layer.setBlendMode", "Dissolve", "Rozpustit"), + ("layer.setBlendMode", "Dancing Dissolve", "Tančící rozpuštění"), + ("layer.setBlendMode", "Darken", "Ztmavit"), + ("layer.setBlendMode", "Multiply", "Násobit"), + ("layer.setBlendMode", "Color Burn", "Ztmavit barvy"), + ("layer.setBlendMode", "Classic Color Burn", "Klasické ztmavení barev"), + ("layer.setBlendMode", "Linear Burn", "Lineárně ztmavit"), + ("layer.setBlendMode", "Darker Color", "Tmavší barva"), + ("layer.setBlendMode", "Add", "Přičíst"), + ("layer.setBlendMode", "Lighten", "Zesvětlit"), + ("layer.setBlendMode", "Screen", "Závoj"), + ("layer.setBlendMode", "Color Dodge", "Zesvětlit barvy"), + ("layer.setBlendMode", "Classic Color Dodge", "Klasické zesvětlení barev"), + ("layer.setBlendMode", "Linear Dodge", "Lineárně zesvětlit"), + ("layer.setBlendMode", "Lighter Color", "Světlejší barva"), + ("layer.setBlendMode", "Overlay", "Překrýt"), + ("layer.setBlendMode", "Soft Light", "Měkké světlo"), + ("layer.setBlendMode", "Hard Light", "Tvrdé světlo"), + ("layer.setBlendMode", "Linear Light", "Lineární světlo"), + ("layer.setBlendMode", "Vivid Light", "Jasné světlo"), + ("layer.setBlendMode", "Pin Light", "Kolíkové světlo"), + ("layer.setBlendMode", "Hard Mix", "Tvrdé míchání"), + ("layer.setBlendMode", "Difference", "Rozdíl"), + ("layer.setBlendMode", "Classic Difference", "Klasický rozdíl"), + ("layer.setBlendMode", "Exclusion", "Vyloučit"), + ("layer.setBlendMode", "Subtract", "Odečíst"), + ("layer.setBlendMode", "Divide", "Dělit"), + ("layer.setBlendMode", "Hue", "Odstín"), + ("layer.setBlendMode", "Saturation", "Sytost"), + ("layer.setBlendMode", "Color", "Barva"), + ("layer.setBlendMode", "Luminosity", "Světlost"), + ("layer.setBlendMode", "Stencil Alpha", "Šablona alfa"), + ("layer.setBlendMode", "Stencil Luma", "Šablona jasu"), + ("layer.setBlendMode", "Silhouette Alpha", "Silueta alfa"), + ("layer.setBlendMode", "Silhouette Luma", "Silueta jasu"), + ("layer.setBlendMode", "Alpha Add", "Přičíst alfu"), + ("layer.setBlendMode", "Luminescent Premul", "Luminiscenční přednásobení"), + ("layer.setBlendMode", "Next Blending Mode", "Následující režim prolnutí"), + ("layer.setBlendMode", "Previous Blending Mode", "Předchozí režim prolnutí"), + ("", "Track Matte", "Maska stopy"), + ("layer.trackMatte", "No Track Matte", "Bez masky stopy"), + ("layer.trackMatte", "Alpha Matte", "Alfa maska"), + ("layer.trackMatte", "Alpha Inverted Matte", "Invertovaná alfa maska"), + ("layer.trackMatte", "Luma Matte", "Maska jasu"), + ("layer.trackMatte", "Luma Inverted Matte", "Invertovaná maska jasu"), + ("layer.trackMatte", "Matte with Layer Above", "Maskovat vrstvou výše"), + ("layer.trackMatte", "Matte with Layer Below", "Maskovat vrstvou níže"), + ("", "Layer Styles", "Styly vrstvy"), + ("layer.style.options", "Layer Style Options...", "Možnosti stylu vrstvy..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "Převést na upravitelné styly"), + ("layer.style.showAll", "Show All", "Zobrazit vše"), + ("layer.style.removeAll", "Remove All", "Odstranit vše"), + ("layer.style.dropShadow", "Drop Shadow", "Vržený stín"), + ("layer.style.innerShadow", "Inner Shadow", "Vnitřní stín"), + ("layer.style.outerGlow", "Outer Glow", "Vnější záře"), + ("layer.style.innerGlow", "Inner Glow", "Vnitřní záře"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "Úkos a reliéf"), + ("layer.style.satin", "Satin", "Satén"), + ("layer.style.colorOverlay", "Color Overlay", "Překrytí barvou"), + ("layer.style.gradientOverlay", "Gradient Overlay", "Překrytí přechodem"), + ("layer.style.stroke", "Stroke", "Tah"), + ("path.groupShapes", "Group Shapes", "Seskupit tvary"), + ("path.ungroupShapes", "Ungroup Shapes", "Zrušit seskupení tvarů"), + ("", "Arrange", "Uspořádat"), + ("layer.arrange", "Bring Layer to Front", "Přenést vrstvu do popředí"), + ("layer.arrange", "Bring Layer Forward", "Posunout vrstvu vpřed"), + ("layer.arrange", "Send Layer Backward", "Posunout vrstvu vzad"), + ("layer.arrange", "Send Layer to Back", "Přenést vrstvu do pozadí"), + ("", "Reveal", "Zobrazit"), + ("layer.revealSource", "Reveal Layer Source in Project", "Zobrazit zdroj vrstvy v projektu"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "Zobrazit vrstvu ve vývojovém diagramu projektu"), + ("comp.revealInProject", "Reveal Composition in Project", "Zobrazit kompozici v projektu"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "Zobrazit chyby výrazů"), + ("", "Create", "Vytvořit"), + ("layer.create", "Convert to Editable Text", "Převést na upravitelný text"), + ("layer.create", "Create Shapes from Text", "Vytvořit tvary z textu"), + ("layer.create", "Create Masks from Text", "Vytvořit masky z textu"), + ("layer.create", "Create Shapes from Vector Layer", "Vytvořit tvary z vektorové vrstvy"), + ("layer.create", "Create Keyframes from Data", "Vytvořit klíčové snímky z dat"), + ("layer.create", "Null Controllers for Positional Points", "Prázdné ovladače pro poziční body"), + ("layer.create", "Null Controllers for Path Points", "Prázdné ovladače pro body cesty"), + ("layer.create", "Nulls Following Path Points", "Prázdné objekty sledující body cesty"), + ("layer.create", "Null Tracing Along Path", "Prázdný objekt pohybující se po cestě"), + ("layer.create", "Create 3D Layer Instance", "Vytvořit instanci 3D vrstvy"), + ("", "Camera", "Kamera"), + ("camera.fromView", "Create Camera from 3D View", "Vytvořit kameru z 3D zobrazení"), + ("camera.stereoRig", "Create Stereo 3D Rig", "Vytvořit stereoskopickou 3D sestavu"), + ("camera.orbitNull", "Create Orbit Null", "Vytvořit prázdný objekt pro obíhání"), + ("camera.fromModel", "Create Cameras from 3D Model", "Vytvořit kamery z 3D modelu"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "Propojit vzdálenost zaostření s bodem zájmu"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "Propojit vzdálenost zaostření s vrstvou"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "Nastavit vzdálenost zaostření na vrstvu"), + ("layer.cameraSettings", "Camera Settings...", "Nastavení kamery..."), + ("view.reset3DView", "Reset 3D View", "Obnovit 3D zobrazení"), + ("", "Light", "Světlo"), + ("light.fromModel", "Create Lights from 3D Model", "Vytvořit světla z 3D modelu"), + ("light.controlWithCamera", "Control Light with Camera", "Ovládat světlo kamerou"), + ("light.environmentBackground", "Create Environment Light Background Layer", "Vytvořit vrstvu pozadí pro světlo prostředí"), + ("", "Material", "Materiál"), + ("material.revealSource", "Reveal Material Source in Project", "Zobrazit zdroj materiálu v projektu"), + ("material.reset", "Reset Material", "Obnovit materiál"), + ("material.duplicateAssign", "Duplicate and Assign Material", "Duplikovat a přiřadit materiál"), + ("layer.autoTrace", "Auto-trace...", "Automaticky obkreslit..."), + ("layer.precompose", "Pre-compose...", "Předkomponovat..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "Rozpoznat střihy scén..."), + ("", "Effect", "Efekt"), + ("window.panel", "Effect Controls", "Ovládací prvky efektu"), + ("effect.applyLast", "Last Effect", "Poslední efekt"), + ("effect.removeAll", "Remove All", "Odstranit vše"), + ("effect.manage", "Manage Effects...", "Spravovat efekty..."), + ("effect.plugins.load", "Load Effect Plug-in...", "Načíst zásuvný modul efektu..."), + ("", "Animation", "Animace"), + ("anim.savePreset", "Save Animation Preset...", "Uložit přednastavení animace..."), + ("anim.applyPreset", "Apply Animation Preset...", "Použít přednastavení animace..."), + ("", "Recent Animation Presets", "Nedávná přednastavení animace"), + ("anim.clearRecentPresets", "Clear Recent Presets", "Vymazat nedávná přednastavení"), + ("anim.browsePresets", "Browse Presets...", "Procházet přednastavení..."), + ("", "Text Animation Presets", "Přednastavení animace textu"), + ("layer.applyTextPreset", "Typewriter", "Psací stroj"), + ("layer.applyTextPreset", "Fade Up Characters", "Postupně zobrazit znaky"), + ("layer.applyTextPreset", "Bounce In Words", "Slova s odskokem"), + ("layer.applyTextPreset", "Tracking In", "Zúžit prostrkání"), + ("layer.applyTextPreset", "Scramble", "Promíchat znaky"), + ("layer.applyTextPreset", "Blur In", "Zaostřit text"), + ("layer.applyTextPreset", "Jitter", "Chvění"), + ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), + ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), + ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), + ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), + ("", "Keyframe Assistant", "Pomocník klíčových snímků"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "Převést zvuk na klíčové snímky"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "Převést výraz na klíčové snímky"), + ("keys.easyEase", "Easy Ease", "Snadné zpomalení"), + ("keys.easyEaseIn", "Easy Ease In", "Snadné zpomalení na vstupu"), + ("keys.easyEaseOut", "Easy Ease Out", "Snadné zpomalení na výstupu"), + ("keys.exponentialScale", "Exponential Scale", "Exponenciální měřítko"), + ("keys.rpfCameraImport", "RPF Camera Import", "Import kamery RPF"), + ("layer.sequence", "Sequence Layers...", "Seřadit vrstvy za sebe..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "Obrátit čas klíčových snímků"), + ("", "Animate Text", "Animovat text"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "Povolit 3D pro jednotlivé znaky"), + ("layer.addTextAnimator", "Anchor Point", "Kotevní bod"), + ("layer.addTextAnimator", "Position", "Pozice"), + ("layer.addTextAnimator", "Scale", "Měřítko"), + ("layer.addTextAnimator", "Skew", "Zkosení"), + ("layer.addTextAnimator", "Rotation", "Otočení"), + ("layer.addTextAnimator", "Opacity", "Krytí"), + ("layer.addTextAnimator", "All Transform Properties", "Všechny vlastnosti transformace"), + ("", "Fill Color", "Barva výplně"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "Odstín"), + ("layer.addTextAnimator", "Saturation", "Sytost"), + ("layer.addTextAnimator", "Brightness", "Jas"), + ("", "Stroke Color", "Barva tahu"), + ("layer.addTextAnimator", "Stroke Width", "Šířka tahu"), + ("layer.addTextAnimator", "Tracking", "Prostrkání"), + ("layer.addTextAnimator", "Line Anchor", "Kotevní bod řádku"), + ("layer.addTextAnimator", "Line Spacing", "Řádkování"), + ("layer.addTextAnimator", "Character Offset", "Posun znaku"), + ("layer.addTextAnimator", "Character Value", "Hodnota znaku"), + ("layer.addTextAnimator", "Blur", "Rozostření"), + ("text.animatorFontAxes", "Variable Font Axes", "Osy variabilního písma"), + ("", "Add Text Selector", "Přidat selektor textu"), + ("text.addSelector", "Range", "Rozsah"), + ("text.addSelector", "Wiggly", "Chvějivý"), + ("text.addSelector", "Expression", "Výraz"), + ("text.removeAllAnimators", "Remove All Text Animators", "Odstranit všechny animátory textu"), + ("prop.setExpression", "Add Expression", "Přidat výraz"), + ("essential.addProperty", "Add Property to Essential Graphics", "Přidat vlastnost do základní grafiky"), + ("prop.separateDimensions", "Separate Dimensions", "Oddělit rozměry"), + ("track.camera", "Track Camera", "Sledovat kameru"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Stabilizátor deformací VFX"), + ("track.motion", "Track Motion", "Sledovat pohyb"), + ("track.stabilize", "Stabilize Motion", "Stabilizovat pohyb"), + ("track.property", "Track this Property", "Sledovat tuto vlastnost"), + ("anim.reveal", "Reveal Properties with Keyframes", "Zobrazit vlastnosti s klíčovými snímky"), + ("anim.reveal", "Reveal Properties with Animation", "Zobrazit vlastnosti s animací"), + ("anim.reveal", "Reveal All Modified Properties", "Zobrazit všechny změněné vlastnosti"), + ("", "View", "Zobrazení"), + ("view.newViewer", "New Viewer", "Nový prohlížeč"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "Rozdělit s novým zamknutým prohlížečem"), + ("view.zoomIn", "Zoom In", "Přiblížit"), + ("view.zoomOut", "Zoom Out", "Oddálit"), + ("", "Resolution", "Rozlišení"), + ("view.res.full", "Full", "Plné"), + ("view.res.half", "Half", "Poloviční"), + ("view.res.third", "Third", "Třetinové"), + ("view.res.quarter", "Quarter", "Čtvrtinové"), + ("view.res.custom", "Custom...", "Vlastní..."), + ("view.displayColorManagement", "Use Display Color Management", "Použít správu barev monitoru"), + ("", "Simulate Output", "Simulovat výstup"), + ("view.simulateOutput", "No Output Simulation", "Bez simulace výstupu"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "Starší Macintosh RGB (gama 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "Internetový standard RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "Lineární (gama 1.0)"), + ("view.customRgb", "My Custom RGB...", "Moje vlastní RGB..."), + ("view.simulateOutput", "Custom...", "Vlastní..."), + ("view.rulers", "Show Rulers", "Zobrazit pravítka"), + ("", "Panel Background Color", "Barva pozadí panelu"), + ("view.panelBackground", "Black", "Černá"), + ("view.panelBackground", "Dark Gray", "Tmavě šedá"), + ("view.panelBackground", "Medium Gray (Default)", "Středně šedá (výchozí)"), + ("view.panelBackground", "Light Gray", "Světle šedá"), + ("view.panelBackground", "White", "Bílá"), + ("view.panelBackground", "Custom", "Vlastní"), + ("view.panelBackground", "Select Custom Background Color...", "Vybrat vlastní barvu pozadí..."), + ("view.guides", "Show Guides", "Zobrazit vodítka"), + ("view.snapToGuides", "Snap to Guides", "Přichytávat k vodítkům"), + ("view.lockGuides", "Lock Guides", "Zamknout vodítka"), + ("view.addGuide", "Add Guide...", "Přidat vodítko..."), + ("view.clearGuides", "Clear Guides", "Vymazat vodítka"), + ("view.importGuides", "Import Guides...", "Importovat vodítka..."), + ("view.exportGuides", "Export Guides...", "Exportovat vodítka..."), + ("view.grid", "Show Grid", "Zobrazit mřížku"), + ("view.snapToGrid", "Snap to Grid", "Přichytávat k mřížce"), + ("view.snapping", "Snapping", "Přichytávání"), + ("view.options", "View Options...", "Možnosti zobrazení..."), + ("view.layerControls", "Show Layer Controls", "Zobrazit ovládací prvky vrstvy"), + ("", "Switch View Layout", "Přepnout rozložení zobrazení"), + ("view.layout", "1 View", "1 zobrazení"), + ("view.layout", "2 Views", "2 zobrazení"), + ("view.layout", "4 Views", "4 zobrazení"), + ("view.shareViewOptions", "Share View Options", "Sdílet možnosti zobrazení"), + ("", "Switch 3D View", "Přepnout 3D zobrazení"), + ("view.3d.activeCamera", "Active Camera", "Aktivní kamera"), + ("view.3d.default", "Default", "Výchozí"), + ("view.3d.front", "Front", "Zepředu"), + ("view.3d.left", "Left", "Zleva"), + ("view.3d.top", "Top", "Shora"), + ("view.3d.back", "Back", "Zezadu"), + ("view.3d.right", "Right", "Zprava"), + ("view.3d.bottom", "Bottom", "Zdola"), + ("view.3d.custom1", "Custom View 1", "Vlastní zobrazení 1"), + ("view.3d.custom2", "Custom View 2", "Vlastní zobrazení 2"), + ("view.3d.custom3", "Custom View 3", "Vlastní zobrazení 3"), + ("", "Assign Shortcut to 3D View", "Přiřadit zkratku 3D zobrazení"), + ("view.3d.last", "Switch to Last 3D View", "Přepnout na poslední 3D zobrazení"), + ("view.lookAtSelected", "Look at Selected Layers", "Zaměřit vybrané vrstvy"), + ("view.lookAtAll", "Look at All Layers", "Zaměřit všechny vrstvy"), + ("time.set", "Go to Time...", "Přejít na čas..."), + ("view.fullScreen", "Enter Full Screen", "Přejít na celou obrazovku"), + ("", "Window", "Okno"), + ("", "Workspace", "Pracovní prostor"), + ("window.workspace", "Default", "Výchozí"), + ("window.workspace", "Review", "Revize"), + ("window.workspace", "Learn", "Výuka"), + ("window.workspace", "Small Screen", "Malá obrazovka"), + ("window.workspace", "Standard", "Standardní"), + ("window.workspace", "All Panels", "Všechny panely"), + ("window.workspace", "Animation", "Animace"), + ("window.workspace", "Color", "Barva"), + ("window.workspace", "Effects", "Efekty"), + ("window.workspace", "Essential Graphics", "Základní grafika"), + ("window.workspace", "Minimal", "Minimální"), + ("window.workspace", "Motion Tracking", "Sledování pohybu"), + ("window.workspace", "Paint", "Malování"), + ("window.workspace", "Text", "Text"), + ("window.workspace", "Undocked Panels", "Odpojené panely"), + ("window.resetWorkspace", "Reset to Saved Layout", "Obnovit uložené rozložení"), + ("window.saveWorkspace", "Save Changes to this Workspace", "Uložit změny tohoto pracovního prostoru"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "Uložit jako nový pracovní prostor..."), + ("window.editWorkspaces", "Edit Workspaces...", "Upravit pracovní prostory..."), + ("", "Assign Shortcut to Workspace", "Přiřadit zkratku pracovnímu prostoru"), + ("window.panel", "Align", "Zarovnání"), + ("window.panel", "Audio", "Zvuk"), + ("window.panel", "Brushes", "Štětce"), + ("window.panel", "Character", "Znak"), + ("window.panel", "Content-Aware Fill", "Výplň podle obsahu"), + ("window.panel", "Effects & Presets", "Efekty a přednastavení"), + ("window.panel", "Essential Graphics", "Základní grafika"), + ("window.panel", "Info", "Informace"), + ("help.inAppTutorials", "Learn", "Výuka"), + ("window.panel", "Lumetri Scopes", "Měřicí přístroje Lumetri"), + ("window.panel", "Mask Interpolation", "Interpolace masek"), + ("window.panel", "Media Browser", "Prohlížeč médií"), + ("window.panel", "Metadata", "Metadata"), + ("window.panel", "Motion Sketch", "Náčrt pohybu"), + ("window.panel", "Paint", "Malování"), + ("window.panel", "Paragraph", "Odstavec"), + ("window.panel", "Preview", "Náhled"), + ("window.panel", "Progress", "Průběh"), + ("window.panel", "Properties", "Vlastnosti"), + ("window.panel", "Script Console", "Konzole skriptů"), + ("window.panel", "Smoother", "Vyhlazení"), + ("window.panel", "Tools", "Nástroje"), + ("window.panel", "Tracker", "Sledování"), + ("window.panel", "Wiggler", "Generátor chvění"), + ("window.panel", "Composition", "Kompozice"), + ("window.panel", "Flowchart", "Vývojový diagram"), + ("window.panel", "Footage", "Záznam"), + ("window.panel", "Layer", "Vrstva"), + ("window.panel", "Project", "Projekt"), + ("window.panel", "Render Queue", "Fronta vykreslování"), + ("window.panel", "Timeline", "Časová osa"), + ("window.panel", "Create Nulls From Paths", "Vytvořit prázdné objekty z cest"), + ("window.panel", "VR Comp Editor", "Editor kompozice VR"), + ("", "Help", "Nápověda"), + ("help.docs", "EffectCraft Help...", "Nápověda k EffectCraft..."), + ("help.docs", "Scripting Help...", "Nápověda ke skriptování..."), + ("help.docs", "Expression Reference...", "Referenční příručka výrazů..."), + ("help.docs", "Effect Reference...", "Referenční příručka efektů..."), + ("anim.browsePresets", "Animation Presets...", "Přednastavení animace..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "Klávesové zkratky..."), + ("help.inAppTutorials", "In-App Tutorials...", "Výukové lekce v aplikaci..."), + ("help.onlineTutorials", "Online Tutorials...", "Výukové lekce online..."), + ("help.systemReport", "System Compatibility Report...", "Zpráva o kompatibilitě systému..."), + ("help.enableLogging", "Enable Logging", "Povolit protokolování"), + ("help.revealLogFile", "Reveal Logging File", "Zobrazit soubor protokolu"), + ("help.discord", "Join the ArtCraft Discord...", "Připojit se na Discord ArtCraft..."), + ("help.reportIssue", "Provide Feedback...", "Poskytnout zpětnou vazbu..."), + ("help.website", "ArtCraft Website", "Web ArtCraft"), + ("help.appPage", "EffectCraft Home Page", "Domovská stránka EffectCraft"), + ("help.github", "EffectCraft on GitHub", "EffectCraft na GitHubu"), + ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), +]; + #[cfg(test)] mod tests { use super::*; @@ -3972,6 +6556,86 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + #[test] + fn czech_translates_menus_without_changing_bindings_or_user_names() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); + app.session.prefs.push_recent("File.ecproj"); + let en = crate::native_menu::build(&app); + let state = crate::native_menu::state_key(&app); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + let cs = crate::native_menu::build(&app); + assert_eq!(language(&app), "cs"); + assert!(czech(&app)); + assert_eq!(code("cs"), "cs"); + assert_ne!(state, crate::native_menu::state_key(&app)); + let bindings = |menu: &crate::native_menu::NativeMenu| { + menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() + }; + assert_eq!(bindings(&en), bindings(&cs)); + assert!(cs.items().iter().any(|i| i.command == "comp.new" && i.label == "Nová kompozice...")); + assert!(cs.items().iter().any(|i| i.command == "layer.newText" && i.label == "Text")); + assert!(cs.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "Snadné zpomalení")); + assert!(cs.items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + for (source, translated) in [ + ("File", "Soubor"), + ("Edit", "Úpravy"), + ("Composition", "Kompozice"), + ("Layer", "Vrstva"), + ("Effect", "Efekt"), + ("Animation", "Animace"), + ("View", "Zobrazení"), + ("Window", "Okno"), + ("Help", "Nápověda"), + ] { + assert_eq!(label(&app, "", source), translated); + } + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + let panel = MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel": "layer"}), shortcut: None }; + assert_eq!(entry(&app, &panel, "Layer: File Čeština".into()), "Vrstva: File Čeština"); + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “File Čeština” Workspace".into()), + "Přiřadit zkratku pracovnímu prostoru „File Čeština“" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Přiřadit zkratku 3D zobrazení „Top“"); + assert_eq!(submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to Workspace".into()), "Přiřadit zkratku pracovnímu prostoru"); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); + assert_eq!(crate::native_menu::build(&app), en); + } + + #[test] + fn czech_undo_redo_keeps_history_labels_verbatim() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None }; + let redo = MenuEntry { label: "Redo".into(), command: "edit.redo".into(), params: json!({}), shortcut: None }; + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Není co vrátit"); + assert_eq!(entry(&app, &redo, "Can't Redo".into()), "Není co zopakovat"); + app.session.history.undo.push(("File Čeština".into(), app.session.project.clone())); + app.session.history.redo.push(("Text Čeština".into(), app.session.project.clone())); + assert_eq!(entry(&app, &undo, "Undo File Čeština".into()), "Zpět File Čeština"); + assert_eq!(entry(&app, &redo, "Redo Text Čeština".into()), "Znovu Text Čeština"); + } + + #[test] + fn czech_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Zobrazit ve Finderu"), + ("Reveal in Explorer", "Zobrazit v Průzkumníku"), + ("Reveal in File Manager", "Zobrazit ve správci souborů"), + ] { + let e = MenuEntry { label: source.into(), command: command.into(), params: json!({}), shortcut: None }; + assert_eq!(label(&app, command, source), translated); + assert_eq!(entry(&app, &e, source.into()), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + assert_eq!(label(&app, command, "My file.mp4"), "My file.mp4"); + } + } + fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -3999,6 +6663,10 @@ mod tests { let mut ukrainian_keys = BTreeSet::new(); let mut spanish_keys = BTreeSet::new(); let mut portuguese_keys = BTreeSet::new(); + let mut russian_keys = BTreeSet::new(); + let mut french_keys = BTreeSet::new(); + let mut german_keys = BTreeSet::new(); + let mut czech_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), @@ -4006,6 +6674,10 @@ mod tests { (UKRAINIAN, &mut ukrainian_keys), (SPANISH, &mut spanish_keys), (PORTUGUESE, &mut portuguese_keys), + (RUSSIAN, &mut russian_keys), + (FRENCH, &mut french_keys), + (GERMAN, &mut german_keys), + (CZECH, &mut czech_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -4032,6 +6704,10 @@ mod tests { assert_eq!(traditional_keys, spanish_keys, "the languages must translate the same entries"); assert_eq!(spanish_keys, portuguese_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, ukrainian_keys, "the languages must translate the same entries"); + assert_eq!(ukrainian_keys, russian_keys, "the languages must translate the same entries"); + assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); + assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); + assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); } /// Exercise each engine spelling on every host: a macOS-only run must still catch a @@ -4113,6 +6789,10 @@ mod tests { (Some("es-419"), "es"), (Some("pt-BR"), "pt-br"), (Some("pt_PT.UTF-8"), "pt-br"), + (Some("cs"), "cs"), + (Some("cs-CZ"), "cs"), + (Some("cs_CZ.UTF-8"), "cs"), + (Some("CS_cz.UTF-8"), "cs"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -4126,6 +6806,7 @@ mod tests { assert_eq!(simplified(&app), system_language() == "zh-hans"); assert_eq!(traditional(&app), system_language() == "zh-hant"); assert_eq!(spanish(&app), system_language() == "es"); + assert_eq!(czech(&app), system_language() == "cs"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"ja"})).unwrap(); assert!(japanese(&app)); app.session.execute("prefs.set", json!({"key":"general.language", "value":"zh-hans"})).unwrap(); @@ -4145,7 +6826,9 @@ mod tests { #[test] fn every_translated_entry_keeps_the_english_shape() { - for (code, catalog) in [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE)] { + for (code, catalog) in + [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)] + { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 117d4b70..733502e1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -44,11 +44,15 @@ fn catalog(language: &str) -> Option<&'static Index> { static HANS: OnceLock = OnceLock::new(); static HANT: OnceLock = OnceLock::new(); static UK: OnceLock = OnceLock::new(); + static PT_BR: OnceLock = OnceLock::new(); + static CS: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), "zh-hant" => (&HANT, TRADITIONAL_CHINESE), "uk" => (&UK, UKRAINIAN), + "pt-br" => (&PT_BR, PORTUGUESE), + "cs" => (&CS, CZECH), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -81,6 +85,9 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { /// Japanese: the same rows, in the same order, as the Chinese catalogs below (the tests keep the two in step). const JAPANESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外観設定…"), + ("Add to favorites", "お気に入りに追加"), + ("Remove from favorites", "お気に入りから削除"), (" OK ", " OK "), (" Delete ", " 削除 "), (" Open Auto-Save ", " 自動保存を開く "), @@ -145,6 +152,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Clear", "消去"), ("Clear list", "リストを消去"), ("Click a key to see its commands.", "キーをクリックすると、割り当てられたコマンドが表示されます。"), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "バーの上をクリックすると不透明度の分岐点、下をクリックするとカラーの分岐点を追加します。", + ), ("Click adds a point (½ height = no change)", "クリックでポイントを追加(高さの½=変化なし)"), ("Click adds a point; drag one off to remove", "クリックでポイントを追加、外へドラッグで削除"), ("Click to make this viewer active", "クリックしてこのビューアーをアクティブ化"), @@ -238,6 +249,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"), ("Frame Target:", "フレームターゲット:"), ("Grab bag", "いろいろ"), + ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), @@ -274,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Light Type", "ライトの種類"), ("Lighter", "明るく"), ("Link Selected Property", "選択したプロパティをリンク"), + ("Location:", "位置:"), ("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"), ("Lock to Zoom", "ズームにロック"), ("Locked: {} — {}", "ロック済み:{} — {}"), @@ -322,6 +335,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), ("No shortcut", "ショートカットなし"), @@ -461,6 +475,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("The Media Browser is not available here (use File ▸ Import)", "ここではメディアブラウザーを使用できません(「ファイル ▸ 読み込み」を使ってください)"), ("The OpenEXR layer shown in red, green, blue and alpha", "赤・緑・青・アルファとして表示するOpenEXRレイヤー"), ("The render queue is empty.", "レンダーキューは空です。"), + ("These adjacent keyframes cannot interpolate because their stop counts differ.", "これらの隣接するキーフレームは分岐点の数が異なるため、補間できません。"), ("This ScriptUI panel has closed", "このScriptUIパネルは閉じられました"), ("This change will create a new solid for this layer.", "この変更により、このレイヤー用に新しい平面が作成されます。"), ("Time", "時間"), @@ -483,6 +498,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Type a command or effect…", "コマンドまたはエフェクトを入力…"), ("URL:", "URL:"), ("Undock Panel", "パネルのドッキングを解除"), + ("Move Panel to New Window", "新規ウィンドウにパネルを移動"), + ("Move Panel to Main Window", "メインウィンドウにパネルを移動"), ("Unlink {}", "{}のリンクを解除"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -512,6 +529,9 @@ const JAPANESE: &[(&str, &str)] = &[ ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外观设置…"), + ("Add to favorites", "添加到收藏"), + ("Remove from favorites", "从收藏中移除"), (" OK ", " 确定 "), (" Delete ", " 删除 "), (" Open Auto-Save ", " 打开自动保存 "), @@ -578,6 +598,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Clear", "清除"), ("Clear list", "清空列表"), ("Click a key to see its commands.", "单击某个按键以查看其命令。"), + ("Click above the bar to add an opacity stop, below it to add a color stop.", "点击渐变条上方添加不透明度色标,点击下方添加颜色色标。"), ("Click adds a point (½ height = no change)", "单击可添加点(上下滑动高度的一半 = 无变化)"), ("Click adds a point; drag one off to remove", "单击可添加点;拖出一个即可删除"), ("Click to make this viewer active", "单击可激活此视图"), @@ -674,6 +695,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Frame Target:", "帧目标:"), ("Freeze", "冻结"), ("Grab bag", "列表"), + ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), @@ -712,6 +734,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Light Type", "灯光类型"), ("Lighter", "更浅"), ("Link Selected Property", "链接所选属性"), + ("Location:", "位置:"), ("Lock Aspect Ratio to {}", "锁定长宽比为 {}"), ("Lock to Zoom", "锁定缩放"), ("Locked: {} — {}", "已锁定:{} — {}"), @@ -757,6 +780,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), ("No shortcut", "无快捷键"), @@ -900,6 +924,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("The Media Browser is not available here (use File ▸ Import)", "此处不支持媒体浏览器(请使用 文件 ▸ 导入)"), ("The OpenEXR layer shown in red, green, blue and alpha", "以红、绿、蓝和 Alpha 显示的 OpenEXR 图层"), ("The render queue is empty.", "渲染队列为空。"), + ("These adjacent keyframes cannot interpolate because their stop counts differ.", "这些相邻关键帧的色标数量不同,无法插值。"), ("This ScriptUI panel has closed", "此 ScriptUI 面板已关闭"), ("This change will create a new solid for this layer.", "此更改将为该图层创建新的纯色。"), ("Time", "时间"), @@ -919,6 +944,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "将面板移至新窗口"), + ("Move Panel to Main Window", "将面板移回主窗口"), ("Unfreeze", "解冻"), ("Unlink {}", "取消链接 {}"), ( @@ -1038,6 +1065,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外觀設定…"), + ("Add to favorites", "加入最愛"), + ("Remove from favorites", "從最愛移除"), (" OK ", " 確定 "), (" Delete ", " 刪除 "), (" Open Auto-Save ", " 開啟自動儲存 "), @@ -1104,6 +1134,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Clear", "清除"), ("Clear list", "清空清單"), ("Click a key to see its commands.", "按一下某個按鍵以查看其命令。"), + ("Click above the bar to add an opacity stop, below it to add a color stop.", "按一下漸變條上方以新增不透明度色標,按一下下方以新增顏色色標。"), ("Click adds a point (½ height = no change)", "按一下可添加點(上下滑動高度的一半 = 無變化)"), ("Click adds a point; drag one off to remove", "按一下可添加點;拖出一個即可刪除"), ("Click to make this viewer active", "按一下可激活此檢視"), @@ -1200,6 +1231,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Frame Target:", "幀目標:"), ("Freeze", "凍結"), ("Grab bag", "清單"), + ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), @@ -1238,6 +1270,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Light Type", "燈光類型"), ("Lighter", "更淺"), ("Link Selected Property", "鏈接所選屬性"), + ("Location:", "位置:"), ("Lock Aspect Ratio to {}", "鎖定長寬比例為 {}"), ("Lock to Zoom", "鎖定縮放"), ("Locked: {} — {}", "已鎖定:{} — {}"), @@ -1283,6 +1316,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), ("No shortcut", "無快速鍵"), @@ -1426,6 +1460,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("The Media Browser is not available here (use File ▸ Import)", "此處不支援媒體瀏覽器(請使用 文件 ▸ 匯入)"), ("The OpenEXR layer shown in red, green, blue and alpha", "以紅、綠、藍和 Alpha 顯示的 OpenEXR 圖層"), ("The render queue is empty.", "算圖佇列為空。"), + ("These adjacent keyframes cannot interpolate because their stop counts differ.", "這些相鄰關鍵影格的色標數量不同,無法內插。"), ("This ScriptUI panel has closed", "此 ScriptUI 面板已關閉"), ("This change will create a new solid for this layer.", "此更改將為該圖層創建新的純色。"), ("Time", "時間"), @@ -1445,6 +1480,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), + ("Move Panel to New Window", "將面板移至新視窗"), + ("Move Panel to Main Window", "將面板移回主視窗"), ("Unfreeze", "解凍"), ("Unlink {}", "取消鏈接 {}"), ( @@ -1561,6 +1598,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. /// Technical names, key names and substituted project content keep their original spelling. const UKRAINIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "Налаштування вигляду…"), + ("Add to favorites", "Додати до вибраного"), + ("Remove from favorites", "Видалити з вибраного"), (" OK ", " Гаразд "), (" Delete ", " Видалити "), (" Open Auto-Save ", " Відкрити автозбереження "), @@ -1573,7 +1613,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ), ("About EffectCraft", "Про EffectCraft"), ("About...", "Про програму..."), + ("Active", "Активна"), ("Active Camera", "Активна камера"), + ("Alpha", "Альфа"), ("Adapt Feature On Every Frame", "Адаптувати ознаку в кожному кадрі"), ("Adaptive Sample Limit:", "Межа адаптивних вибірок:"), ("Add Animator", "Додати аніматор"), @@ -1594,6 +1636,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("All six face cameras turn together; the scene is not changed.", "Усі шість камер граней повертаються разом; сцена залишається без змін."), ("Anchor:", "Опорна точка:"), ("Aperture", "Діафрагма"), + ("Appearance Mode", "Режим оформлення"), + ("Appearance Mode: {}", "Режим оформлення: {}"), ("Apply Dimensions:", "Застосувати виміри:"), ("Apply Motion To:", "Застосувати рух до:"), ("Arrange", "Упорядкувати"), @@ -1606,6 +1650,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Blending Mode", "Режим змішування"), ("Blur", "Розмиття"), ("Blur Level", "Рівень розмиття"), + ("Boundary", "Межа"), ("Browser Storage", "Сховище браузера"), ("Browse…", "Огляд…"), ("Brush Dynamics", "Динаміка пензля"), @@ -1625,6 +1670,10 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Clear", "Очистити"), ("Clear list", "Очистити список"), ("Click a key to see its commands.", "Клацніть клавішу, щоб переглянути її команди."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "Клацніть над смугою, щоб додати точку непрозорості, під нею — щоб додати точку кольору.", + ), ("Click adds a point (½ height = no change)", "Клацніть, щоб додати точку (½ висоти = без змін)"), ("Click adds a point; drag one off to remove", "Клацніть, щоб додати точку; перетягніть її за межі, щоб вилучити"), ("Click to make this viewer active", "Клацніть, щоб зробити цю область перегляду активною"), @@ -1655,6 +1704,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Continuous (Lock Outgoing to Incoming)", "Безперервна (прирівняти вихідну швидкість до вхідної)"), ("Cue Point", "Сигнальна точка"), ("Current Render", "Поточний рендеринг"), + ("Dark", "Темне"), + ("Dark Theme", "Темна тема"), ("Darker", "Темніше"), ("Default Output:", "Стандартний вихід:"), ("Default: {}", "Стандартне: {}"), @@ -1717,7 +1768,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Frame Rate:", "Частота кадрів:"), ("Frame Render Time {}ms", "Час рендерингу кадру {} мс"), ("Frame Target:", "Цільовий фрейм:"), + ("Freeze", "Заморозити"), ("Grab bag", "Список"), + ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), @@ -1751,9 +1804,12 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Leave all attributes in \"{}\"", "Залишити всі атрибути в «{}»"), ("Licence: {}", "Ліцензія: {}"), ("Ligatures", "Лігатури"), + ("Light", "Світле"), + ("Light Theme", "Світла тема"), ("Light Type", "Тип світла"), ("Lighter", "Світліше"), ("Link Selected Property", "Зв’язати виділену властивість"), + ("Location:", "Розташування:"), ("Lock Aspect Ratio to {}", "Зафіксувати співвідношення сторін на {}"), ("Lock to Zoom", "Прив’язати до масштабу"), ("Locked: {} — {}", "Заблоковано: {} — {}"), @@ -1805,6 +1861,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), ("No shortcut", "Немає клавіатурного скорочення"), @@ -1831,6 +1888,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Original Duration:", "Початкова тривалість:"), ("Output Cycle: {}", "Цикл виводу: {}"), ("Output To:", "Вивести до:"), + ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), @@ -1929,6 +1987,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Start Timecode:", "Початковий часовий код:"), ("Start: {} Duration: {}", "Початок: {} Тривалість: {}"), ("Step {} of {}: {}", "Крок {} з {}: {}"), + ("Stop", "Зупинити"), ("Stop {} at {}°", "Колірна точка {} на {}°"), ("Stored in: {}", "Збережено в: {}"), ("Stretch", "Розтягнення"), @@ -1936,6 +1995,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Stylistic Sets", "Стилістичні набори"), ("Subpixel Positioning", "Субпіксельне позиціювання"), ("Swap Paint Colors (X)", "Поміняти місцями кольори малювання (X)"), + ("Sync with System", "Синхронізувати з системою"), ("System Default", "Стандартне системне"), ("Table", "Таблиця"), ("Template name", "Назва шаблону"), @@ -1944,6 +2004,10 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("The Media Browser is not available here (use File ▸ Import)", "Браузер матеріалів тут недоступний (скористайтеся меню «Файл ▸ Імпорт»)"), ("The OpenEXR layer shown in red, green, blue and alpha", "Шар OpenEXR, показаний у червоному, зеленому, синьому та альфа-каналі"), ("The render queue is empty.", "Черга рендерингу порожня."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Ці сусідні ключові кадри не можуть інтерполюватися, бо кількість точок у них різна.", + ), ("This ScriptUI panel has closed", "Цю панель ScriptUI закрито"), ("This change will create a new solid for this layer.", "Ця зміна створить новий матеріал суцільного кольору для цього шару."), ("Time", "Час"), @@ -1966,6 +2030,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Type a command or effect…", "Введіть команду або ефект…"), ("URL:", "URL:"), ("Undock Panel", "Від’єднати панель"), + ("Unfreeze", "Розморозити"), + ("Move Panel to New Window", "Перемістити панель у нове вікно"), + ("Move Panel to Main Window", "Перемістити панель у головне вікно"), ("Unlink {}", "Від’єднати {}"), ( "Use this option to place the currently selected layers together into a new intermediate composition.", @@ -1992,6 +2059,1066 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} використовується в:"), ("Track", "Відстежити"), + // Strings a panel used to hand to a helper as a bare literal: the helper draws them (the + // viewer's toolbar tooltips and popup items, the solid dialog's checkbox), so the lookup has + // to reach the catalog from inside the helper. Kept at the end so the catalogs stay parallel. + ("3D Reference Axes", "Опорні осі 3D"), + ("Affect all layers that use this solid", "Застосувати до всіх шарів, що використовують цей суцільний колір"), + ("Choose grid and guide options", "Вибрати параметри сітки та напрямних"), + ("Colorize", "Розфарбувати"), + ("Custom", "Власна"), + ("Custom...", "Власна..."), + ("Fit", "Підігнати"), + ("Fit up to 100%", "Підігнати (макс. 100%)"), + ("Full", "Повна"), + ("Half", "Половина"), + ("Only this layer uses this solid.", "Цей суцільний колір використовує лише цей шар."), + ("Proportional Grid", "Пропорційна сітка"), + ("Quarter", "Чверть"), + ("Region of Interest", "Область інтересу"), + ("Reset Exposure", "Скинути експозицію"), + ("Show Channel and Color Management Settings", "Показати канал і параметри керування кольором"), + ("Show Snapshot (hold)", "Показати знімок (утримуйте)"), + ("Take Snapshot", "Зробити знімок"), + ("Third", "Третина"), + ("Toggle Mask and Shape Path Visibility", "Перемкнути видимість контурів масок і фігур"), + ("Toggle Transparency Grid", "Перемкнути сітку прозорості"), + // Technical terms and proper nouns: the project settings, the import and export forms, the + // viewer's channel and Fast Previews popups, the keyframe dialogs and the About window. The + // terms that read the same in Ukrainian (Display P3, HDTV (Rec. 709), RGB, H.264, ProRes…) + // need no row: a missing row falls back to the English source, which is the decision. + ("16 bits per channel", "16 бітів на канал"), + ("32 bits per channel (float)", "32 біти на канал (з плаваючою комою)"), + ("8 bits per channel", "8 бітів на канал"), + ("Adaptive Resolution", "Адаптивна роздільність"), + ("Adobe-style built-in", "Вбудований у стилі Adobe"), + ("Blue", "Синій"), + ("Current Settings", "Поточні налаштування"), + ("Disable Per-character 3D", "Вимкнути 3D для кожного символу"), + ("Draft", "Чорнова"), + ("Enable Per-character 3D", "Увімкнути 3D для кожного символу"), + ("Fast Draft", "Швидка чорнова"), + ("Fast Previews (Draft 3D, Faster Effects)", "Швидкий перегляд (чорновий 3D, швидші ефекти)"), + ("Green", "Зелений"), + ("Import Image Sequence", "Імпорт послідовності зображень"), + ("Import PDF / Illustrator File", "Імпорт файлу PDF / Illustrator"), + ("Import Photoshop File", "Імпорт файлу Photoshop"), + ("Interpret Unlabeled Alpha As", "Інтерпретувати непозначену альфу як"), + ("Join the ArtCraft Discord", "Приєднатися до Discord ArtCraft"), + ("Mercury GPU Acceleration ({})", "Прискорення Mercury GPU ({})"), + ("Mercury Software Only", "Лише програмний Mercury"), + ("OCIO (built-in ACES config)", "OCIO (вбудована конфігурація ACES)"), + ("Off (Final Quality)", "Вимкнено (остаточна якість)"), + ("OpenGL (compatibility)", "OpenGL (сумісність)"), + ("Opus Bitrate: {} kbps", "Бітрейт Opus: {} кбіт/с"), + ("RGB Straight", "RGB (прямий)"), + ("Red", "Червоний"), + ("Tone map", "Тональне відображення"), + ("Use In-Window Menu Bar on macOS", "Використовувати смугу меню у вікні на macOS"), + ("Wireframe", "Каркас"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (стандартний)"), + // The Home screen (#574). + ("New Project", "Новий проєкт"), + ("Open Project…", "Відкрити проєкт…"), + ("Templates", "Шаблони"), + ("EffectCraft home page", "Домашня сторінка EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft на GitHub"), + ("Motion graphics and visual effects · version {}", "Анімована графіка та візуальні ефекти · версія {}"), + ("New Composition", "Нова композиція"), + ("Open Demo Project", "Відкрити демонстраційний проєкт"), + ("Import Footage…", "Імпортувати матеріал…"), + ("Filter recent files", "Фільтрувати нещодавні файли"), + ("NAME", "НАЗВА"), + ("OPENED", "ВІДКРИТО"), + ("SIZE", "РОЗМІР"), + ("KIND", "ТИП"), + ("Your recent files will show here", "Тут з’являться ваші нещодавні файли"), + ("Create a new project, or open the demo project to look around.", "Створіть новий проєкт або відкрийте демонстраційний, щоб ознайомитися з програмою."), + ("No recent files match the filter", "Немає нещодавніх файлів, що відповідають фільтру"), + ("Try another name or folder.", "Спробуйте іншу назву або теку."), + ("Just now", "Щойно"), + ("Yesterday", "Учора"), + // `format_ago` picks between one and many, while Ukrainian declines the noun for 1, for 2–4 + // and for 5 and up; the invariant unit abbreviations read correctly for every count. + ("{} minute ago", "{} хв тому"), + ("{} minutes ago", "{} хв тому"), + ("{} hour ago", "{} год тому"), + ("{} hours ago", "{} год тому"), + ("{} day ago", "{} дн. тому"), + ("{} days ago", "{} дн. тому"), + ("EffectCraft project", "Проєкт EffectCraft"), + ("After Effects project", "Проєкт After Effects"), + ("Lottie animation", "Анімація Lottie"), + ("Project", "Проєкт"), +]; + +// Brazilian Portuguese. +const PORTUGUESE: &[(&str, &str)] = &[ + (" OK ", " OK "), + (" Delete ", " Excluir "), + (" Open Auto-Save ", " Abrir salvamento automático "), + ("(missing)", "(ausente)"), + ("360 View Orientation", "Orientação da vista 360°"), + ("3:2 Pulldown: {}", "Pulldown 3:2: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Um compositor de código aberto, desenvolvido em clean-room, para motion graphics e efeitos visuais: nativo no macOS, Windows e Linux, e no navegador. Parte da família de aplicativos criativos ArtCraft.", + ), + ("About EffectCraft", "Sobre o EffectCraft"), + ("About...", "Sobre..."), + ("Active Camera", "Câmera ativa"), + ("Adapt Feature On Every Frame", "Adaptar o recurso em cada quadro"), + ("Adaptive Sample Limit:", "Limite de amostras adaptativas:"), + ("Add Animator", "Adicionar animador"), + ("Add Comment", "Adicionar comentário"), + ("Add Formatting", "Adicionar formatação"), + ("Add Group", "Adicionar grupo"), + ("Add Mirror", "Adicionar espelho"), + ("Add Selected As", "Adicionar selecionados como"), + ("Add a comment", "Adicionar um comentário"), + ("Add a shape, fill, stroke or path operation", "Adicionar uma forma, preenchimento, traço ou operação de caminho"), + ("Add to Composition", "Adicionar à composição"), + ("Add to Favorites", "Adicionar aos favoritos"), + ("Add:", "Adicionar:"), + ("Adjust Exposure (stops)", "Ajustar exposição (stops)"), + ("Adjust composition duration to the time span of the selected layers", "Ajustar a duração da composição ao intervalo de tempo das camadas selecionadas"), + ("Advanced 3D", "3D avançado"), + ("Align Layers to:", "Alinhar camadas a:"), + ("All six face cameras turn together; the scene is not changed.", "As seis câmeras das faces giram juntas; a cena não é alterada."), + ("Anchor:", "Âncora:"), + ("Aperture", "Abertura"), + ("Apply Dimensions:", "Aplicar dimensões:"), + ("Apply Motion To:", "Aplicar movimento a:"), + ("Arrange", "Organizar"), + ("Assign", "Atribuir"), + ("Auto", "Automático"), + ("Auto-saves", "Salvamentos automáticos"), + ("Back", "Voltar"), + ("Background Color:", "Cor do plano de fundo:"), + ("Blend Mode:", "Modo de mesclagem:"), + ("Blending Mode", "Modo de mesclagem"), + ("Blur", "Desfoque"), + ("Blur Level", "Nível de desfoque"), + ("Browser Storage", "Armazenamento do navegador"), + ("Browse…", "Procurar…"), + ("Brush Dynamics", "Dinâmica do pincel"), + ("Caching frames…", "Armazenando quadros em cache…"), + ("Cancel", "Cancelar"), + ("Casts Shadows", "Projeta sombras"), + ("Change Renderer…", "Alterar renderizador…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Alterar um atalho cria uma predefinição personalizada; a padrão permanece como está.", + ), + ("Channel", "Canal"), + ("Channel:", "Canal:"), + ("Chapter:", "Capítulo:"), + ("Choose...", "Escolher..."), + ("Classic 3D", "3D clássico"), + ("Clear", "Limpar"), + ("Clear list", "Limpar lista"), + ("Click a key to see its commands.", "Clique em uma tecla para ver seus comandos."), + ("Click adds a point (½ height = no change)", "Clique adiciona um ponto (½ altura = sem alteração)"), + ("Click adds a point; drag one off to remove", "Clique adiciona um ponto; arraste um para fora para remover"), + ("Click to make this viewer active", "Clique para tornar este visualizador ativo"), + ("Click to reveal the expression in the timeline", "Clique para revelar a expressão na linha do tempo"), + ("Click to show the active composition here", "Clique para mostrar a composição ativa aqui"), + ("Click, then click in the Composition panel", "Clique e depois clique no painel Composição"), + ("Clone Options", "Opções de clone"), + ("Clone Source Overlay:", "Sobreposição da origem do clone:"), + ("Close", "Fechar"), + ("Close Panel", "Fechar painel"), + ("Close tutorial", "Fechar tutorial"), + ("Color", "Cor"), + ("Color Depth: {}", "Profundidade de cor: {}"), + ("Color:", "Cor:"), + ("Colour map", "Mapa de cores"), + ("Columns", "Colunas"), + ("Comment:", "Comentário:"), + ("Community", "Comunidade"), + ("Composition", "Composição"), + ("Composition Mini-Flowchart", "Minifluxograma da composição"), + ("Composition Name:", "Nome da composição:"), + ("Composition Settings ▸ 3D Renderer", "Configurações da composição ▸ Renderizador 3D"), + ("Composition Settings…", "Configurações da composição…"), + ("Composition marker bin: drag a marker to the time ruler", "Compartimento de marcadores da composição: arraste um marcador para a régua de tempo"), + ("Cone Angle", "Ângulo do cone"), + ("Cone Feather", "Suavização do cone"), + ("Constrain Proportions", "Restringir proporções"), + ("Continuous (Lock Outgoing to Incoming)", "Contínuo (travar saída na entrada)"), + ("Cue Point", "Ponto de cue"), + ("Current Render", "Renderização atual"), + ("Darker", "Mais escuro"), + ("Default Output:", "Saída padrão:"), + ("Default: {}", "Padrão: {}"), + ("Defaults", "Padrões"), + ("Delete", "Excluir"), + ("Delete Selected Points", "Excluir pontos selecionados"), + ("Delete selected project items", "Excluir itens de projeto selecionados"), + ("Delete this template", "Excluir este modelo"), + ("Depth of Field", "Profundidade de campo"), + ("Discord", "Discord"), + ("Distribute Layers:", "Distribuir camadas:"), + ("Don't Recover", "Não recuperar"), + ("Don't Save", "Não salvar"), + ("Double-click a layer to open it in the Layer panel", "Clique duas vezes em uma camada para abri-la no painel Camada"), + ("Double-click footage in the Project panel to open it here", "Clique duas vezes em uma mídia no painel Projeto para abri-la aqui"), + ("Double-click to edit the comment", "Clique duas vezes para editar o comentário"), + ("Download", "Baixar"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Arraste propriedades da linha do tempo para cá,\nou use Animação > Adicionar propriedade aos Gráficos essenciais.", + ), + ("Drag to change", "Arraste para alterar"), + ("Drag to create a guide", "Arraste para criar uma guia"), + ("Drag to slip the source", "Arraste para deslizar a origem"), + ("Drop Frame", "Drop frame"), + ("Duplicate", "Duplicar"), + ("Duration:", "Duração:"), + ("Edit Gradient…", "Editar gradiente…"), + ("Edit Target: {}", "Editar destino: {}"), + ("Edit in the Timeline", "Editar na linha do tempo"), + ("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."), + ("Effects & Presets options", "Opções de Efeitos e predefinições"), + ("Effects: {}", "Efeitos: {}"), + ("Empty folder", "Pasta vazia"), + ("Enable Depth of Field", "Ativar profundidade de campo"), + ("Enable Expression", "Ativar expressão"), + ("Enhance", "Aprimorar"), + ("Essential Properties — {}", "Propriedades essenciais: {}"), + ("Event", "Evento"), + ("Export Template…", "Exportar modelo…"), + ("Export...", "Exportar..."), + ("Expression Language Menu", "Menu de linguagem de expressão"), + ("Expression pick whip: drag onto a property", "Pick whip de expressão: arraste até uma propriedade"), + ("Expression:", "Expressão:"), + ("Eyedropper: click, then click in the Composition panel", "Conta-gotas: clique e depois clique no painel Composição"), + ("Falloff", "Atenuação"), + ("Falloff Distance", "Distância de atenuação"), + ("Favorites", "Favoritos"), + ("Field Render: {}", "Renderização de campos: {}"), + ("Figures", "Numerais"), + ("Fill Target", "Destino do preenchimento"), + ("Finished", "Concluído"), + ("Flowchart: {}", "Fluxograma: {}"), + ("Focus Distance", "Distância de foco"), + ("Follow Feature Rotation and Scale", "Acompanhar rotação e escala do recurso"), + ("Footage: {}", "Mídia: {}"), + ("Format: {}", "Formato: {}"), + ("Frame Aspect Ratio: {}", "Proporção do quadro: {}"), + ("Frame Blending: {}", "Mesclagem de quadros: {}"), + ("Frame Rate:", "Taxa de quadros:"), + ("Frame Render Time {}ms", "Tempo de renderização do quadro {}ms"), + ("Frame Target:", "Destino do quadro:"), + ("Grab bag", "Lista"), + ("Grid", "Grade"), + ("Guide Layers: {}", "Camadas de guia: {}"), + ("Guides", "Guias"), + ("Height:", "Altura:"), + ("Hide This", "Ocultar isto"), + ("Histogram", "Histograma"), + ("Hold In Place", "Manter no lugar"), + ("Home", "Início"), + ("If Confidence is Below", "Se a confiança for inferior a"), + ("Import", "Importar"), + ("Import and Add to Composition", "Importar e adicionar à composição"), + ("Import...", "Importar..."), + ("Imported media", "Mídia importada"), + ("Influence:", "Influência:"), + ("Install from File…", "Instalar a partir de arquivo…"), + ("Intensity", "Intensidade"), + ("Inverted", "Invertido"), + ("Join the ArtCraft community on Discord", "Entre na comunidade ArtCraft no Discord"), + ("Key: {}", "Tecla: {}"), + ("LEARN", "APRENDER"), + ("Label", "Rótulo"), + ("Label Colors and Names:", "Cores e nomes dos rótulos:"), + ("Label:", "Rótulo:"), + ("Latest auto-save: {}", "Último salvamento automático: {}"), + ("Layer", "Camada"), + ("Layer controls", "Controles da camada"), + ("Layer {}", "Camada {}"), + ("Layer:", "Camada:"), + ("Layer: {}", "Camada: {}"), + ("Learn", "Aprender"), + ("Leave all attributes in \"{}\"", "Manter todos os atributos em \"{}\""), + ("Licence: {}", "Licença: {}"), + ("Ligatures", "Ligaduras"), + ("Light Type", "Tipo de luz"), + ("Lighter", "Mais claro"), + ("Link Selected Property", "Vincular propriedade selecionada"), + ("Lock Aspect Ratio to {}", "Travar proporção em {}"), + ("Lock to Zoom", "Travar no zoom"), + ("Locked: {} — {}", "Travado: {} / {}"), + ("Log:", "Registro:"), + ("Loop (Trace Path)", "Laço (traçar caminho)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OU Apache-2.0 • Fontes: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Menu de proporção de ampliação"), + ("Make Comp Size", "Ajustar ao tamanho da composição"), + ("Masks", "Máscaras"), + ("Maximize Panel", "Maximizar painel"), + ("Media Replacement", "Substituição de mídia"), + ("Mesh:", "Malha:"), + ("Diameter:", "Diâmetro:"), + ("Method Used: {} Average Error: {} pixels", "Método usado: {} Erro médio: {} pixels"), + ("Models folder: {}", "Pasta de modelos: {}"), + ("Modifiers:", "Modificadores:"), + ("More", "Mais"), + ("More ArtCraft apps", "Mais aplicativos ArtCraft"), + ("More tutorials", "Mais tutoriais"), + ("Motion Blur", "Desfoque de movimento"), + ("Motion Blur: {}", "Desfoque de movimento: {}"), + ("Motion Sketch: drag to record", "Motion Sketch: arraste para gravar"), + ("Move Down", "Mover para baixo"), + ("Move Up", "Mover para cima"), + ("Move to Project Root", "Mover para a raiz do projeto"), + ("Name", "Nome"), + ("Name:", "Nome:"), + ("Navigation", "Navegação"), + ("New", "Novo"), + ("New Comp from Selection", "Nova composição a partir da seleção"), + ("New Duration:", "Nova duração:"), + ("New composition name:", "Nome da nova composição:"), + ("New from Render Queue item:", "Novo a partir do item da fila de renderização:"), + ("New from Template", "Novo a partir de modelo"), + ("Next", "Próximo"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Bom trabalho. Escolha outro tutorial na guia Aprender da tela Início."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Nenhum ambiente de RV ainda. Crie um com Composição ▸ RV ▸ Criar ambiente de RV…", + ), + ("No audio layer selected", "Nenhuma camada de áudio selecionada"), + ("No background jobs running", "Nenhuma tarefa em segundo plano em execução"), + ("No composition markers", "Nenhum marcador de composição"), + ("No contributor data was built into this copy.", "Nenhum dado de colaboradores foi incluído nesta cópia."), + ("No device list from this host; the system default output is used.", "Este host não forneceu lista de dispositivos; a saída padrão do sistema é usada."), + ("No layer selected", "Nenhuma camada selecionada"), + ("No matching effects or presets", "Nenhum efeito ou predefinição correspondente"), + ("No matching fonts", "Nenhuma fonte correspondente"), + ("No model credits were built into this copy.", "Nenhum crédito de modelos foi incluído nesta cópia."), + ("No nested compositions", "Nenhuma composição aninhada"), + ("No shortcut", "Sem atalho"), + ("No unlocked layer selected", "Nenhuma camada desbloqueada selecionada"), + ("No video", "Sem vídeo"), + ("None", "Nenhum"), + ("Not applied: tick it in the list to add it.", "Não aplicado: marque-o na lista para adicioná-lo."), + ("Not assigned", "Não atribuído"), + ( + "Note: Shadows are only cast from layers with Cast Shadows enabled.", + "Observação: as sombras só são projetadas por camadas com Projetar sombras ativado.", + ), + ("Notify", "Notificar"), + ("OK", "OK"), + ("Offset:", "Deslocamento:"), + ("On ({}) stop:", "Ao parar ({}):"), + ("One-Node Camera", "Câmera de um nó"), + ("Opacity:", "Opacidade:"), + ("Open Composition", "Abrir composição"), + ("Open Last Saved Project", "Abrir último projeto salvo"), + ("Open New Composition", "Abrir nova composição"), + ("Open Output", "Abrir saída"), + ("Open a composition to build Essential Graphics", "Abra uma composição para criar Gráficos essenciais"), + ("Open a composition to see its scopes", "Abra uma composição para ver seus scopes"), + ("Open a composition to track motion.", "Abra uma composição para rastrear movimento."), + ("Opus Tuning: {}", "Ajuste do Opus: {}"), + ("Original Duration:", "Duração original:"), + ("Output Cycle: {}", "Ciclo de saída: {}"), + ("Output To:", "Saída para:"), + ("Parameter Name:", "Nome do parâmetro:"), + ("Parent pick whip: drag onto a layer", "Pick whip de pai: arraste até uma camada"), + ("Pixel Aspect Ratio:", "Proporção de pixel:"), + ("Playing on Video Preview", "Reproduzindo na visualização de vídeo"), + ("Post-Render Action: {}", "Ação pós-renderização: {}"), + ("Preserve frame rate when nested or in render queue", "Preservar a taxa de quadros quando aninhada ou na fila de renderização"), + ("Preserve resolution when nested", "Preservar a resolução quando aninhada"), + ("Preset", "Predefinição"), + ("Preset:", "Predefinição:"), + ("Presets", "Predefinições"), + ("Previous", "Anterior"), + ("Primary:", "Primário:"), + ("Process Before Match", "Processar antes de corresponder"), + ("Profile: {}", "Perfil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Profundidade de cor do projeto: clique para alternar entre 8/16/32 bpc, Alt+clique para Configurações do projeto", + ), + ("Project: {}", "Projeto: {}"), + ("Projects", "Projetos"), + ("Property pick whip: drag onto a property", "Pick whip de propriedade: arraste até uma propriedade"), + ("Protected Region", "Região protegida"), + ("Proxy Use: {}", "Uso de proxy: {}"), + ("Push All to Comp", "Enviar tudo para a composição"), + ("Radius", "Raio"), + ("Rate Control: {}", "Controle de taxa: {}"), + ("Recent", "Recentes"), + ("Recording", "Gravando"), + ("Remove", "Remover"), + ("Rename", "Renomear"), + ("Render: {} ms • UI {} fps{}", "Renderização: {} ms • IU {} fps{}"), + ("Renderer:", "Renderizador:"), + ("Request Persistent Storage", "Solicitar armazenamento persistente"), + ("Reset", "Redefinir"), + ("Reset Labels", "Redefinir rótulos"), + ("Reset Preset", "Redefinir predefinição"), + ("Reset to Default", "Redefinir para o padrão"), + ("Reset to Saved Layout", "Redefinir para o layout salvo"), + ("Resolution/Down Sample Factor Popup", "Menu de resolução/fator de redução de amostragem"), + ("Restore Panel Size", "Restaurar tamanho do painel"), + ("Reverse the order", "Inverter a ordem"), + ("Revert All", "Reverter tudo"), + ("Roman Hanging Punctuation", "Pontuação romana suspensa"), + ("Roving:", "Roving:"), + ("Ruler zero point (drag to move, double-click to reset)", "Ponto zero da régua (arraste para mover, clique duas vezes para redefinir)"), + ("Rulers", "Réguas"), + ("Run", "Executar"), + ("Sample Format: {}", "Formato da amostra: {}"), + ("Samples Per Frame:", "Amostras por quadro:"), + ("Save from #{}", "Salvar a partir de #{}"), + ("Saved {}", "{} salvo"), + ("Script… (⌘/Ctrl+Enter runs)", "Script… (⌘/Ctrl+Enter executa)"), + ("Search commands or shortcuts", "Pesquisar comandos ou atalhos"), + ("Select", "Selecionar"), + ("Select a command to change its shortcut.", "Selecione um comando para alterar seu atalho."), + ("Select a layer to see its effects", "Selecione uma camada para ver seus efeitos"), + ("Select a layer to see its properties", "Selecione uma camada para ver suas propriedades"), + ("({}) Selected Objects", "({}) objetos selecionados"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Selecione uma camada com máscara ou caminho de forma (ou a propriedade de caminho), ou pinos de Puppet.", + ), + ("Select a text layer", "Selecione uma camada de texto"), + ("Select an item in the Project panel", "Selecione um item no painel Projeto"), + ("Select animated properties to show them in the Graph Editor", "Selecione propriedades animadas para exibi-las no Editor de gráficos"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Selecione o pino líder e depois Shift+clique nos pinos que devem segui-lo (cabelo, tecido, caudas)", + ), + ("Set Ground Plane and Origin", "Definir plano do solo e origem"), + ("Set Paint Colors to Black and White (D)", "Definir cores de pintura como preto e branco (D)"), + ("Settings", "Configurações"), + ("Settings Name:", "Nome das configurações:"), + ("Shadow Darkness", "Escuridão da sombra"), + ("Shadow Diffusion", "Difusão da sombra"), + ("Shadows", "Sombras"), + ("Shape…", "Forma…"), + ("Show", "Exibir"), + ("Show Layer Name", "Exibir nome da camada"), + ("Show Source Name", "Exibir nome da origem"), + ("Show me", "Mostre-me"), + ("Show:", "Exibir:"), + ("Shutter Angle:", "Ângulo do obturador:"), + ("Shutter Phase:", "Fase do obturador:"), + ("Size", "Tamanho"), + ("Skip", "Ignorar"), + ("Snapping", "Ajuste magnético"), + ("Snapping options", "Opções de ajuste magnético"), + ("Snapshot", "Instantâneo"), + ("Solo Supported Properties", "Solo de propriedades suportadas"), + ("Solo Switches: {}", "Chaves de solo: {}"), + ("Sort", "Ordenar"), + ("Spacing:", "Espaçamento:"), + ("Spatial Interpolation:", "Interpolação espacial:"), + ("Speed:", "Velocidade:"), + ("Start Timecode:", "Timecode inicial:"), + ("Start: {} Duration: {}", "Início: {} Duração: {}"), + ("Step {} of {}: {}", "Etapa {} de {}: {}"), + ("Stop {} at {}°", "Parada {} em {}°"), + ("Stored in: {}", "Armazenado em: {}"), + ("Stretch", "Esticar"), + ("Stretch Factor:", "Fator de esticamento:"), + ("Stylistic Sets", "Conjuntos estilísticos"), + ("Subpixel Positioning", "Posicionamento em subpixel"), + ("Swap Paint Colors (X)", "Trocar cores de pintura (X)"), + ("System Default", "Padrão do sistema"), + ("Table", "Tabela"), + ("Template name", "Nome do modelo"), + ("Template: {}\n{}", "Modelo: {}\n{}"), + ("Temporal Interpolation:", "Interpolação temporal:"), + ("The Media Browser is not available here (use File ▸ Import)", "O Media Browser não está disponível aqui (use Arquivo ▸ Importar)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "A camada OpenEXR exibida em vermelho, verde, azul e alfa"), + ("The render queue is empty.", "A fila de renderização está vazia."), + ("This ScriptUI panel has closed", "Este painel ScriptUI foi fechado"), + ("This change will create a new solid for this layer.", "Esta alteração criará um novo sólido para esta camada."), + ("Time", "Tempo"), + ("Time Stretch…", "Esticamento de tempo…"), + ("Time:", "Tempo:"), + ("Time: {}", "Tempo: {}"), + ("Title/Action Safe", "Área segura de título/ação"), + ("Title/action safe", "Área segura de título/ação"), + ("Toggle Switches / Modes", "Alternar chaves / modos"), + ("Track Name:", "Nome da faixa:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Os modelos treinados estão disponíveis no aplicativo para desktop; o navegador usa o mecanismo clássico.", + ), + ("Transform", "Transformar"), + ("Transparency grid", "Grade de transparência"), + ("Tutorial complete", "Tutorial concluído"), + ("Two-Node Camera", "Câmera de dois nós"), + ("Type", "Tipo"), + ("Type a command or effect…", "Digite um comando ou efeito…"), + ("URL:", "URL:"), + ("Undock Panel", "Desencaixar painel"), + ("Unlink {}", "Desvincular {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Use esta opção para colocar as camadas selecionadas juntas em uma nova composição intermediária.", + ), + ("VR Environments", "Ambientes de RV"), + ("Value:", "Valor:"), + ("Variable Font Axes", "Eixos da fonte variável"), + ("Video Codec: {}", "Codec de vídeo: {}"), + ("View:", "Exibição:"), + ("Welcome to EffectCraft", "Boas-vindas ao EffectCraft"), + ("Width:", "Largura:"), + ("You can undo this.", "Você pode desfazer isto."), + ("Your changes will be lost if you don't save them.", "Suas alterações serão perdidas se você não as salvar."), + ("Zoom", "Zoom"), + ("built-in", "integrado"), + ("frames per second", "quadros por segundo"), + ("template name", "nome do modelo"), + ("{} contributors · {} commits", "{} colaboradores · {} commits"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB por quadro de 8 bpc"), + ("{} — coming soon", "{} em breve"), + ("{} — double-click to open, {}-click to reveal", "{}: clique duas vezes para abrir, {}+clique para revelar"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} é usado por:"), +]; + +/// Czech (cs): original translations covering every row in the Chinese panel catalogs. +const CZECH: &[(&str, &str)] = &[ + (" OK ", " OK "), + (" Delete ", " Odstranit "), + (" Open Auto-Save ", " Otevřít automatickou zálohu "), + ("(missing)", "(chybí)"), + ("360 View Orientation", "Orientace zobrazení 360°"), + ("3:2 Pulldown: {}", "Převod 3:2 pulldown: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "Kompoziční nástroj s otevřeným zdrojovým kódem pro pohyblivou grafiku a vizuální efekty, vyvinutý nezávisle metodou clean-room. Běží nativně na macOS, Windows a Linuxu i v prohlížeči. Patří do rodiny kreativních aplikací ArtCraft.", + ), + ("About EffectCraft", "O aplikaci EffectCraft"), + ("About...", "O aplikaci…"), + ("Active", "Aktivní"), + ("Active Camera", "Aktivní kamera"), + ("Alpha", "Alfa"), + ("Adapt Feature On Every Frame", "Přizpůsobovat sledovaný prvek v každém snímku"), + ("Adaptive Sample Limit:", "Limit adaptivního vzorkování:"), + ("Add Animator", "Přidat animátor"), + ("Add Comment", "Přidat komentář"), + ("Add Formatting", "Přidat formátování"), + ("Add Group", "Přidat skupinu"), + ("Add Mirror", "Přidat zrcadlo"), + ("Add Selected As", "Přidat vybrané jako"), + ("Add a comment", "Přidat komentář"), + ("Add a shape, fill, stroke or path operation", "Přidat tvar, výplň, tah nebo operaci s cestou"), + ("Add to Composition", "Přidat do kompozice"), + ("Add to Favorites", "Přidat do oblíbených"), + ("Add:", "Přidat:"), + ("Adjust Exposure (stops)", "Upravit expozici (v expozičních stupních)"), + ("Adjust composition duration to the time span of the selected layers", "Přizpůsobit délku kompozice časovému rozsahu vybraných vrstev"), + ("Advanced 3D", "Pokročilé 3D"), + ("Align Layers to:", "Zarovnat vrstvy k:"), + ("All six face cameras turn together; the scene is not changed.", "Všech šest kamer snímajících stěny se otáčí společně; scéna se nemění."), + ("Anchor:", "Kotevní bod:"), + ("Aperture", "Clona"), + ("Appearance Mode", "Režim vzhledu"), + ("Appearance Mode: {}", "Režim vzhledu: {}"), + ("Apply Dimensions:", "Použít rozměry:"), + ("Apply Motion To:", "Použít pohyb na:"), + ("Arrange", "Uspořádat"), + ("Assign", "Přiřadit"), + ("Auto", "Automaticky"), + ("Auto-saves", "Automatické zálohy"), + ("Back", "Zpět"), + ("Background Color:", "Barva pozadí:"), + ("Blend Mode:", "Režim prolnutí:"), + ("Blending Mode", "Režim prolnutí"), + ("Blur", "Rozostření"), + ("Blur Level", "Úroveň rozostření"), + ("Boundary", "Hranice"), + ("Browser Storage", "Úložiště prohlížeče"), + ("Browse…", "Procházet…"), + ("Brush Dynamics", "Dynamika štětce"), + ("Caching frames…", "Ukládání snímků do mezipaměti…"), + ("Cancel", "Zrušit"), + ("Casts Shadows", "Vrhat stíny"), + ("Change Renderer…", "Změnit vykreslovací modul…"), + ( + "Changing a shortcut creates a custom preset; the default stays as it is.", + "Změna zkratky vytvoří vlastní přednastavení; výchozí přednastavení zůstane zachované.", + ), + ("Channel", "Kanál"), + ("Channel:", "Kanál:"), + ("Chapter:", "Kapitola:"), + ("Choose...", "Vybrat…"), + ("Classic 3D", "Klasické 3D"), + ("Clear", "Vymazat"), + ("Clear list", "Vymazat seznam"), + ("Click a key to see its commands.", "Kliknutím na klávesu zobrazíte její příkazy."), + ("Click adds a point (½ height = no change)", "Kliknutím přidáte bod (½ výšky = beze změny)"), + ("Click adds a point; drag one off to remove", "Kliknutím přidáte bod; přetažením mimo jej odstraníte"), + ("Click to make this viewer active", "Kliknutím aktivujete tento prohlížeč"), + ("Click to reveal the expression in the timeline", "Kliknutím zobrazíte výraz na časové ose"), + ("Click to show the active composition here", "Kliknutím zde zobrazíte aktivní kompozici"), + ("Click, then click in the Composition panel", "Klikněte sem a poté do panelu Kompozice"), + ("Clone Options", "Možnosti klonování"), + ("Clone Source Overlay:", "Překrytí zdroje klonování:"), + ("Close", "Zavřít"), + ("Close Panel", "Zavřít panel"), + ("Close tutorial", "Zavřít výukovou lekci"), + ("Color", "Barva"), + ("Color Depth: {}", "Barevná hloubka: {}"), + ("Color:", "Barva:"), + ("Colour map", "Barevná mapa"), + ("Columns", "Sloupce"), + ("Comment:", "Komentář:"), + ("Community", "Komunita"), + ("Composition", "Kompozice"), + ("Composition Mini-Flowchart", "Malý vývojový diagram kompozice"), + ("Composition Name:", "Název kompozice:"), + ("Composition Settings ▸ 3D Renderer", "Nastavení kompozice ▸ 3D vykreslovací modul"), + ("Composition Settings…", "Nastavení kompozice…"), + ("Composition marker bin: drag a marker to the time ruler", "Zásobník značek kompozice: přetáhněte značku na časové pravítko"), + ("Cone Angle", "Úhel kužele"), + ("Cone Feather", "Prolnutí okrajů kužele"), + ("Constrain Proportions", "Zachovat proporce"), + ("Continuous (Lock Outgoing to Incoming)", "Spojitá (svázat výstupní tečnu se vstupní)"), + ("Cue Point", "Bod události"), + ("Current Render", "Aktuální vykreslování"), + ("Dark", "Tmavý"), + ("Dark Theme", "Tmavý motiv"), + ("Darker", "Tmavší"), + ("Default Output:", "Výchozí výstup:"), + ("Default: {}", "Výchozí: {}"), + ("Defaults", "Výchozí hodnoty"), + ("Delete", "Odstranit"), + ("Delete Selected Points", "Odstranit vybrané body"), + ("Delete selected project items", "Odstranit vybrané položky projektu"), + ("Delete this template", "Odstranit tuto šablonu"), + ("Depth of Field", "Hloubka ostrosti"), + ("Discord", "Discord"), + ("Distribute Layers:", "Rozmístit vrstvy:"), + ("Don't Recover", "Neobnovovat"), + ("Don't Save", "Neukládat"), + ("Double-click a layer to open it in the Layer panel", "Dvojklikem otevřete vrstvu v panelu Vrstva"), + ("Double-click footage in the Project panel to open it here", "Dvojklikem na záznam v panelu Projekt jej otevřete zde"), + ("Double-click to edit the comment", "Dvojklikem upravíte komentář"), + ("Download", "Stáhnout"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "Přetáhněte sem vlastnosti z časové osy,\nnebo použijte Animace > Přidat vlastnost do základní grafiky.", + ), + ("Drag to change", "Tažením změníte hodnotu"), + ("Drag to create a guide", "Tažením vytvoříte vodítko"), + ("Drag to slip the source", "Tažením posunete zdroj v čase"), + ("Drop Frame", "Vynechávat čísla snímků"), + ("Duplicate", "Duplikovat"), + ("Duration:", "Délka trvání:"), + ("Edit Gradient…", "Upravit přechod…"), + ("Edit Target: {}", "Upravit cíl: {}"), + ("Edit in the Timeline", "Upravit na časové ose"), + ("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."), + ("Effects & Presets options", "Možnosti efektů a přednastavení"), + ("Effects: {}", "Efekty: {}"), + ("Empty folder", "Prázdná složka"), + ("Enable Depth of Field", "Povolit hloubku ostrosti"), + ("Enable Expression", "Povolit výraz"), + ("Enhance", "Vylepšit"), + ("Essential Properties — {}", "Základní vlastnosti — {}"), + ("Event", "Událost"), + ("Export Template…", "Exportovat šablonu…"), + ("Export...", "Exportovat…"), + ("Expression Language Menu", "Nabídka jazyka výrazů"), + ("Expression pick whip: drag onto a property", "Propojovací nástroj výrazů: přetáhněte na vlastnost"), + ("Expression:", "Výraz:"), + ("Eyedropper: click, then click in the Composition panel", "Kapátko: klikněte sem a poté do panelu Kompozice"), + ("Falloff", "Útlum"), + ("Falloff Distance", "Vzdálenost útlumu"), + ("Favorites", "Oblíbené"), + ("Field Render: {}", "Vykreslování půlsnímků: {}"), + ("Figures", "Číslice"), + ("Fill Target", "Cíl výplně"), + ("Finished", "Dokončeno"), + ("Flowchart: {}", "Vývojový diagram: {}"), + ("Focus Distance", "Vzdálenost zaostření"), + ("Follow Feature Rotation and Scale", "Sledovat otočení a měřítko prvku"), + ("Footage: {}", "Záznam: {}"), + ("Format: {}", "Formát: {}"), + ("Frame Aspect Ratio: {}", "Poměr stran snímku: {}"), + ("Frame Blending: {}", "Prolnutí snímků: {}"), + ("Frame Rate:", "Snímková frekvence:"), + ("Frame Render Time {}ms", "Čas vykreslení snímku {}ms"), + ("Frame Target:", "Cílový rámec:"), + ("Freeze", "Zmrazit"), + ("Grab bag", "Mozaika"), + ("Grid", "Mřížka"), + ("Guide Layers: {}", "Pomocné vrstvy: {}"), + ("Guides", "Vodítka"), + ("Height:", "Výška:"), + ("Hide This", "Skrýt toto"), + ("Histogram", "Histogram"), + ("Hold In Place", "Pevný časový bod"), + ("Home", "Domů"), + ("If Confidence is Below", "Pokud je spolehlivost nižší než"), + ("Import", "Importovat"), + ("Import and Add to Composition", "Importovat a přidat do kompozice"), + ("Import...", "Importovat…"), + ("Imported media", "Importovaná média"), + ("Influence:", "Vliv:"), + ("Install from File…", "Nainstalovat ze souboru…"), + ("Intensity", "Intenzita"), + ("Inverted", "Invertovaná"), + ("Join the ArtCraft community on Discord", "Připojit se ke komunitě ArtCraft na Discordu"), + ("Key: {}", "Klávesa: {}"), + ("LEARN", "VÝUKA"), + ("Label", "Štítek"), + ("Label Colors and Names:", "Barvy a názvy štítků:"), + ("Label:", "Štítek:"), + ("Latest auto-save: {}", "Nejnovější automatická záloha: {}"), + ("Layer", "Vrstva"), + ("Layer controls", "Ovládací prvky vrstvy"), + ("Layer {}", "Vrstva {}"), + ("Layer:", "Vrstva:"), + ("Layer: {}", "Vrstva: {}"), + ("Learn", "Výuka"), + ("Leave all attributes in \"{}\"", "Ponechat všechny atributy v „{}“"), + ("Licence: {}", "Licence: {}"), + ("Ligatures", "Ligatury"), + ("Light", "Světlý"), + ("Light Theme", "Světlý motiv"), + ("Light Type", "Typ světla"), + ("Lighter", "Světlejší"), + ("Link Selected Property", "Propojit vybranou vlastnost"), + ("Lock Aspect Ratio to {}", "Zamknout poměr stran na {}"), + ("Lock to Zoom", "Svázat s přiblížením"), + ("Locked: {} — {}", "Zamknuto: {} — {}"), + ("Log:", "Protokol:"), + ("Loop (Trace Path)", "Smyčka (sledovat cestu)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • Písma: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "Nabídka míry přiblížení"), + ("Make Comp Size", "Nastavit velikost kompozice"), + ("Masks", "Masky"), + ("Maximize Panel", "Maximalizovat panel"), + ("Media Replacement", "Nahrazení médií"), + ("Mesh:", "Síť:"), + ("Diameter:", "Průměr:"), + ("Method Used: {} Average Error: {} pixels", "Použitá metoda: {} Průměrná chyba: {} px"), + ("Models folder: {}", "Složka modelů: {}"), + ("Modifiers:", "Modifikátory:"), + ("More", "Další"), + ("More ArtCraft apps", "Další aplikace ArtCraft"), + ("More tutorials", "Další výukové lekce"), + ("Motion Blur", "Rozostření pohybem"), + ("Motion Blur: {}", "Rozostření pohybem: {}"), + ("Motion Sketch: drag to record", "Náčrt pohybu: tažením zaznamenáte pohyb"), + ("Move Down", "Posunout dolů"), + ("Move Up", "Posunout nahoru"), + ("Move to Project Root", "Přesunout do kořene projektu"), + ("Name", "Název"), + ("Name:", "Název:"), + ("Navigation", "Navigace"), + ("New", "Nový"), + ("New Comp from Selection", "Nová kompozice z výběru"), + ("New Duration:", "Nová délka trvání:"), + ("New composition name:", "Název nové kompozice:"), + ("New from Render Queue item:", "Nová šablona z položky fronty vykreslování:"), + ("New from Template", "Nový ze šablony"), + ("Next", "Další"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "Hotovo. Vyberte si další lekci na kartě Výuka na domovské obrazovce."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "Zatím nejsou žádná prostředí VR. Vytvořte je přes Kompozice ▸ VR ▸ Vytvořit prostředí VR…", + ), + ("No audio layer selected", "Není vybrána zvuková vrstva"), + ("No background jobs running", "Na pozadí neběží žádné úlohy"), + ("No composition markers", "Kompozice nemá žádné značky"), + ("No contributor data was built into this copy.", "Tato sestava neobsahuje údaje o přispěvatelích."), + ("No device list from this host; the system default output is used.", "Hostitel neposkytuje seznam zařízení; používá se výchozí systémový výstup."), + ("No layer selected", "Není vybrána vrstva"), + ("No matching effects or presets", "Žádné odpovídající efekty ani přednastavení"), + ("No matching fonts", "Žádná odpovídající písma"), + ("No model credits were built into this copy.", "Tato sestava neobsahuje údaje o použitých modelech."), + ("No nested compositions", "Žádné vnořené kompozice"), + ("No shortcut", "Bez zkratky"), + ("No unlocked layer selected", "Není vybrána odemknutá vrstva"), + ("No video", "Žádné video"), + ("None", "Žádné"), + ("Not applied: tick it in the list to add it.", "Nepoužito: pro přidání položku zaškrtněte v seznamu."), + ("Not assigned", "Nepřiřazeno"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "Poznámka: Stíny vrhají pouze vrstvy s povolenou volbou Vrhat stíny."), + ("Notify", "Upozornit"), + ("OK", "OK"), + ("Offset:", "Posun:"), + ("On ({}) stop:", "Při zastavení ({}):"), + ("One-Node Camera", "Jednouzlová kamera"), + ("Opacity:", "Krytí:"), + ("Open Composition", "Otevřít kompozici"), + ("Open Last Saved Project", "Otevřít poslední uložený projekt"), + ("Open New Composition", "Otevřít novou kompozici"), + ("Open Output", "Otevřít výstup"), + ("Open a composition to build Essential Graphics", "Otevřete kompozici pro sestavení základní grafiky"), + ("Open a composition to see its scopes", "Otevřete kompozici pro zobrazení měřicích přístrojů"), + ("Open a composition to track motion.", "Otevřete kompozici pro sledování pohybu."), + ("Opus Tuning: {}", "Ladění Opus: {}"), + ("Original Duration:", "Původní délka trvání:"), + ("Output Cycle: {}", "Cyklus výstupu: {}"), + ("Output To:", "Výstup do:"), + ("Overlay", "Překrytí"), + ("Parameter Name:", "Název parametru:"), + ("Parent pick whip: drag onto a layer", "Propojovací nástroj nadřazené vrstvy: přetáhněte na vrstvu"), + ("Pixel Aspect Ratio:", "Poměr stran pixelů:"), + ("Playing on Video Preview", "Přehrávání ve videonáhledu"), + ("Post-Render Action: {}", "Akce po vykreslení: {}"), + ("Preserve frame rate when nested or in render queue", "Zachovat snímkovou frekvenci při vnoření nebo ve frontě vykreslování"), + ("Preserve resolution when nested", "Zachovat rozlišení při vnoření"), + ("Preset", "Přednastavení"), + ("Preset:", "Přednastavení:"), + ("Presets", "Přednastavení"), + ("Previous", "Předchozí"), + ("Primary:", "Hlavní kompozice:"), + ("Process Before Match", "Předzpracování před hledáním shody"), + ("Profile: {}", "Profil: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "Barevná hloubka projektu: kliknutím přepnete 8/16/32 bpc, kliknutím s Alt otevřete Nastavení projektu", + ), + ("Project: {}", "Projekt: {}"), + ("Projects", "Projekty"), + ("Property pick whip: drag onto a property", "Propojovací nástroj vlastností: přetáhněte na vlastnost"), + ("Protected Region", "Chráněná oblast"), + ("Proxy Use: {}", "Použití zástupných souborů: {}"), + ("Push All to Comp", "Přenést vše do kompozice"), + ("RGB", "RGB"), + ("Radius", "Poloměr"), + ("Rate Control: {}", "Řízení datového toku: {}"), + ("Recent", "Nedávné"), + ("Recording", "Záznam pohybu"), + ("Remove", "Odstranit"), + ("Rename", "Přejmenovat"), + ("Render: {} ms • UI {} fps{}", "Vykreslení: {} ms • UI {} fps{}"), + ("Renderer:", "Vykreslovací modul:"), + ("Request Persistent Storage", "Požádat o trvalé úložiště"), + ("Reset", "Obnovit"), + ("Reset Labels", "Obnovit štítky"), + ("Reset Preset", "Obnovit přednastavení"), + ("Reset to Default", "Obnovit výchozí hodnoty"), + ("Reset to Saved Layout", "Obnovit uložené rozložení"), + ("Resolution/Down Sample Factor Popup", "Nabídka rozlišení a míry podvzorkování"), + ("Restore Panel Size", "Obnovit velikost panelu"), + ("Reverse the order", "Obrátit pořadí"), + ("Revert All", "Vrátit všechny změny"), + ("Roman Hanging Punctuation", "Předsazená interpunkce latinky"), + ("Roving:", "Plovoucí v čase:"), + ("Ruler zero point (drag to move, double-click to reset)", "Nulový bod pravítka (tažením posunete, dvojklikem obnovíte)"), + ("Rulers", "Pravítka"), + ("Run", "Spustit"), + ("Sample Format: {}", "Formát vzorků: {}"), + ("Samples Per Frame:", "Vzorků na snímek:"), + ("Save from #{}", "Uložit z #{}"), + ("Saved {}", "Uloženo {}"), + ("Script… (⌘/Ctrl+Enter runs)", "Skript… (⌘/Ctrl+Enter spustí)"), + ("Search commands or shortcuts", "Hledat příkazy nebo zkratky"), + ("Select", "Vybrat"), + ("Select a command to change its shortcut.", "Vyberte příkaz pro změnu jeho zkratky."), + ("Select a layer to see its effects", "Vyberte vrstvu pro zobrazení jejích efektů"), + ("Select a layer to see its properties", "Vyberte vrstvu pro zobrazení jejích vlastností"), + ("({}) Selected Objects", "Vybrané objekty ({})"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "Vyberte vrstvu s cestou masky či tvaru (nebo vlastnost cesty), případně čepy nástroje Loutka.", + ), + ("Select a text layer", "Vyberte textovou vrstvu"), + ("Select an item in the Project panel", "Vyberte položku v panelu Projekt"), + ("Select animated properties to show them in the Graph Editor", "Vyberte animované vlastnosti pro zobrazení v editoru grafů"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "Vyberte řídicí čep a potom se Shiftem klikněte na čepy, které jej mají následovat (vlasy, látka, ocasy)", + ), + ("Set Ground Plane and Origin", "Nastavit základní rovinu a počátek"), + ("Set Paint Colors to Black and White (D)", "Nastavit barvy malování na černou a bílou (D)"), + ("Settings", "Nastavení"), + ("Settings Name:", "Název nastavení:"), + ("Shadow Darkness", "Tmavost stínu"), + ("Shadow Diffusion", "Rozptýlení stínu"), + ("Shadows", "Stíny"), + ("Shape…", "Tvar…"), + ("Show", "Zobrazit"), + ("Show Layer Name", "Zobrazit název vrstvy"), + ("Show Source Name", "Zobrazit název zdroje"), + ("Show me", "Ukázat postup"), + ("Show:", "Zobrazit:"), + ("Shutter Angle:", "Úhel závěrky:"), + ("Shutter Phase:", "Fáze závěrky:"), + ("Size", "Velikost"), + ("Skip", "Přeskočit"), + ("Snapping", "Přichytávání"), + ("Snapping options", "Možnosti přichytávání"), + ("Snapshot", "Snímek náhledu"), + ("Solo Supported Properties", "Zobrazit pouze podporované vlastnosti"), + ("Solo Switches: {}", "Přepínače sólo: {}"), + ("Sort", "Seřadit"), + ("Spacing:", "Rozestupy:"), + ("Spatial Interpolation:", "Prostorová interpolace:"), + ("Speed:", "Rychlost:"), + ("Start Timecode:", "Počáteční časový kód:"), + ("Start: {} Duration: {}", "Začátek: {} Délka trvání: {}"), + ("Step {} of {}: {}", "Krok {} z {}: {}"), + ("Stop", "Zastavit"), + ("Stop {} at {}°", "Bod přechodu {} na {}°"), + ("Stored in: {}", "Uloženo v: {}"), + ("Stretch", "Roztažení"), + ("Stretch Factor:", "Koeficient roztažení:"), + ("Stylistic Sets", "Stylistické sady"), + ("Subpixel Positioning", "Subpixelové umístění"), + ("Swap Paint Colors (X)", "Prohodit barvy malování (X)"), + ("Sync with System", "Synchronizovat se systémem"), + ("System Default", "Výchozí systémové"), + ("Table", "Tabulka"), + ("Template name", "Název šablony"), + ("Template: {}\n{}", "Šablona: {}\n{}"), + ("Temporal Interpolation:", "Časová interpolace:"), + ("The Media Browser is not available here (use File ▸ Import)", "Prohlížeč médií zde není k dispozici (použijte Soubor ▸ Importovat)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "Vrstva OpenEXR zobrazená v červeném, zeleném, modrém a alfa kanálu"), + ("The render queue is empty.", "Fronta vykreslování je prázdná."), + ("This ScriptUI panel has closed", "Tento panel ScriptUI byl zavřen"), + ("This change will create a new solid for this layer.", "Tato změna vytvoří pro tuto vrstvu novou plnou vrstvu."), + ("Time", "Čas"), + ("Time Stretch…", "Roztažení času…"), + ("Time:", "Čas:"), + ("Time: {}", "Čas: {}"), + ("Title/Action Safe", "Bezpečné oblasti titulků a akce"), + ("Title/action safe", "Bezpečné oblasti titulků a akce"), + ("Toggle Switches / Modes", "Přepnout přepínače / režimy"), + ("Track Name:", "Název stopy:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "Trénované modely jsou dostupné v desktopové aplikaci; prohlížeč používá klasické zpracování.", + ), + ("Transform", "Transformace"), + ("Transparency grid", "Mřížka průhlednosti"), + ("Tutorial complete", "Výuková lekce dokončena"), + ("Two-Node Camera", "Dvouzlová kamera"), + ("Type", "Typ"), + ("Type a command or effect…", "Zadejte příkaz nebo efekt…"), + ("URL:", "URL:"), + ("Undock Panel", "Odpojit panel"), + ("Unfreeze", "Zrušit zmrazení"), + ("Unlink {}", "Odpojit {}"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "Tato volba vloží aktuálně vybrané vrstvy společně do nové mezilehlé kompozice.", + ), + ("VR Environments", "Prostředí VR"), + ("Value:", "Hodnota:"), + ("Variable Font Axes", "Osy variabilního písma"), + ("Video Codec: {}", "Video kodek: {}"), + ("View:", "Zobrazení:"), + ("Welcome to EffectCraft", "Vítejte v EffectCraft"), + ("Width:", "Šířka:"), + ("You can undo this.", "Tuto akci lze vrátit zpět."), + ("Your changes will be lost if you don't save them.", "Pokud změny neuložíte, budou ztraceny."), + ("Zoom", "Přiblížení"), + ("built-in", "vestavěné"), + ("frames per second", "snímků za sekundu"), + ("template name", "název šablony"), + ("{} contributors · {} commits", "{} přispěvatelů · {} commitů"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB na snímek s 8bpc"), + ("{} — coming soon", "{} — již brzy"), + ("{} — double-click to open, {}-click to reveal", "{} — dvojklikem otevřete, kliknutím s {} zobrazíte umístění"), + ("⌘/Ctrl-drag a pin to record its motion", "Tažením čepu s ⌘/Ctrl zaznamenáte jeho pohyb"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} používají:"), + ("3D Reference Axes", "Referenční osy 3D"), + ("Affect all layers that use this solid", "Ovlivnit všechny vrstvy používající tuto plnou vrstvu"), + ("Choose grid and guide options", "Vybrat možnosti mřížky a vodítek"), + ("Colorize", "Kolorovat"), + ("Custom", "Vlastní"), + ("Custom...", "Vlastní…"), + ("Fit", "Přizpůsobit"), + ("Fit up to 100%", "Přizpůsobit nejvýše do 100 %"), + ("Full", "Plné"), + ("Half", "Poloviční"), + ("Only this layer uses this solid.", "Tuto plnou vrstvu používá pouze tato vrstva."), + ("Proportional Grid", "Proporcionální mřížka"), + ("Quarter", "Čtvrtinové"), + ("Region of Interest", "Oblast zájmu"), + ("Reset Exposure", "Obnovit expozici"), + ("Show Channel and Color Management Settings", "Zobrazit nastavení kanálů a správy barev"), + ("Show Snapshot (hold)", "Zobrazit snímek náhledu (podržet)"), + ("Take Snapshot", "Pořídit snímek náhledu"), + ("Third", "Třetinové"), + ("Toggle Mask and Shape Path Visibility", "Přepnout viditelnost cest masek a tvarů"), + ("Toggle Transparency Grid", "Přepnout mřížku průhlednosti"), + ("16 bits per channel", "16 bitů na kanál"), + ("32 bits per channel (float)", "32 bitů na kanál (plovoucí čárka)"), + ("8 bits per channel", "8 bitů na kanál"), + ("Adaptive Resolution", "Adaptivní rozlišení"), + ("Adobe-style built-in", "Vestavěné ve stylu Adobe"), + ("Blue", "Modrý"), + ("Current Settings", "Aktuální nastavení"), + ("Disable Per-character 3D", "Zakázat 3D pro jednotlivé znaky"), + ("Draft", "Pracovní"), + ("Enable Per-character 3D", "Povolit 3D pro jednotlivé znaky"), + ("Fast Draft", "Rychlé pracovní"), + ("Fast Previews (Draft 3D, Faster Effects)", "Rychlé náhledy (pracovní 3D, rychlejší efekty)"), + ("Green", "Zelený"), + ("Import Image Sequence", "Importovat sekvenci obrázků"), + ("Import PDF / Illustrator File", "Importovat soubor PDF / Illustrator"), + ("Import Photoshop File", "Importovat soubor Photoshop"), + ("Interpret Unlabeled Alpha As", "Interpretovat neoznačenou alfu jako"), + ("Join the ArtCraft Discord", "Připojit se na Discord ArtCraft"), + ("Mercury GPU Acceleration ({})", "GPU akcelerace Mercury ({})"), + ("Mercury Software Only", "Pouze softwarové zpracování Mercury"), + ("OCIO (built-in ACES config)", "OCIO (vestavěná konfigurace ACES)"), + ("Off (Final Quality)", "Vypnuto (finální kvalita)"), + ("OpenGL (compatibility)", "OpenGL (kompatibilita)"), + ("Opus Bitrate: {} kbps", "Datový tok Opus: {} kbps"), + ("RGB Straight", "RGB bez přednásobení"), + ("Red", "Červený"), + ("Tone map", "Mapování tónů"), + ("Use In-Window Menu Bar on macOS", "Použít nabídku v okně na macOS"), + ("Wireframe", "Drátový model"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (výchozí)"), + ("New Project", "Nový projekt"), + ("Open Project…", "Otevřít projekt…"), + ("Templates", "Šablony"), + ("EffectCraft home page", "Domovská stránka EffectCraft"), + ("EffectCraft on GitHub", "EffectCraft na GitHubu"), + ("Motion graphics and visual effects · version {}", "Pohyblivá grafika a vizuální efekty · verze {}"), + ("New Composition", "Nová kompozice"), + ("Open Demo Project", "Otevřít ukázkový projekt"), + ("Import Footage…", "Importovat záznam…"), + ("Filter recent files", "Filtrovat nedávné soubory"), + ("NAME", "NÁZEV"), + ("OPENED", "OTEVŘENO"), + ("SIZE", "VELIKOST"), + ("KIND", "DRUH"), + ("Your recent files will show here", "Zde se zobrazí vaše nedávné soubory"), + ("Create a new project, or open the demo project to look around.", "Vytvořte nový projekt nebo otevřete ukázkový projekt a prozkoumejte aplikaci."), + ("No recent files match the filter", "Filtru neodpovídají žádné nedávné soubory"), + ("Try another name or folder.", "Zkuste jiný název nebo složku."), + ("Just now", "Právě teď"), + ("Yesterday", "Včera"), + ("{} minute ago", "Před {} minutou"), + ("{} minutes ago", "Před {} minutami"), + ("{} hour ago", "Před {} hodinou"), + ("{} hours ago", "Před {} hodinami"), + ("{} day ago", "Před {} dnem"), + ("{} days ago", "Před {} dny"), + ("EffectCraft project", "Projekt EffectCraft"), + ("After Effects project", "Projekt After Effects"), + ("Lottie animation", "Animace Lottie"), + ("Project", "Projekt"), + ("Click above the bar to add an opacity stop, below it to add a color stop.", "Kliknutím nad pruh přidáte bod krytí, kliknutím pod něj bod barvy."), + ("Gradient Editor", "Editor přechodu"), + ("Location:", "Umístění:"), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.", + ), + ("Variable fonts only", "Jen proměnná písma"), ]; #[cfg(test)] @@ -2011,6 +3138,7 @@ mod tests { ("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")), ("panels/content_fill_panel.rs", include_str!("../panels/content_fill_panel.rs")), ("panels/delete_items.rs", include_str!("../panels/delete_items.rs")), + ("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")), ("panels/dialogs.rs", include_str!("../panels/dialogs.rs")), ("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")), ("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs")), @@ -2086,7 +3214,34 @@ mod tests { const UNITS: &[&str] = &[" px", "%", "·", "s", "ms", "°", "fps", " bpc", " mm", " s", " %", " pixels"]; fn catalogs() -> Vec<(&'static str, &'static str)> { - JAPANESE.iter().copied().chain(SIMPLIFIED_CHINESE.iter().copied()).chain(TRADITIONAL_CHINESE.iter().copied()).chain(UKRAINIAN.iter().copied()).collect() + JAPANESE + .iter() + .copied() + .chain(SIMPLIFIED_CHINESE.iter().copied()) + .chain(TRADITIONAL_CHINESE.iter().copied()) + .chain(UKRAINIAN.iter().copied()) + .chain(PORTUGUESE.iter().copied()) + .chain(CZECH.iter().copied()) + .collect() + } + + #[test] + fn czech_panels_translate_and_keep_project_content_and_argument_order() { + set_current("cs"); + assert_eq!(tr("Composition Name:"), "Název kompozice:"); + assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); + assert_eq!(tr_args("Template: {}\n{}", &[&"Title", &"My description"]), "Šablona: Title\nMy description"); + assert_eq!(tr_args("Leave all attributes in \"{}\"", &[&"File Čeština"]), "Ponechat všechny atributy v „File Čeština“"); + assert_eq!( + tr_args("{} — double-click to open, {}-click to reveal", &[&"File.ecproj", &"Alt"]), + "File.ecproj — dvojklikem otevřete, kliknutím s Alt zobrazíte umístění" + ); + assert_eq!(tr_args("Proxy Use: {}", &[]), "Použití zástupných souborů: "); + set_current("en"); + assert_eq!(tr("Composition Name:"), "Composition Name:"); } /// A row that translates nothing, repeats a source string, or changes the number of values the @@ -2096,8 +3251,8 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); - // Japanese and Ukrainian may lag the Chinese catalogs: a missing row falls back to English. - for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN)] { + // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. + for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); assert!(extra.is_empty(), "the {name} catalog lists source strings the Chinese ones don't: {extra:?}"); } @@ -2142,6 +3297,19 @@ mod tests { assert_eq!(tr("Composition Name:"), "Composition Name:"); } + /// Brazilian Portuguese panels translate, fill values in order and keep project content. + #[test] + fn brazilian_portuguese_panels_translate_and_keep_project_content() { + set_current("pt-br"); + assert_eq!(tr("Composition Name:"), "Nome da composição:"); + assert_eq!(tr("Cancel"), "Cancelar"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Etapa 1 de 3: Colour"); + assert_eq!(tr(" OK "), " OK "); + set_current("en"); + assert_eq!(tr("Cancel"), "Cancel"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 91dd0f85..949ea377 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -22,6 +22,7 @@ pub enum Icon { Ellipse, Polygon, Star, + StarFilled, Pen, PenAdd, PenDelete, @@ -147,6 +148,8 @@ impl Pen16<'_> { let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); self.painter.add(PathShape::closed_line(v, PathStroke::new(self.width * self.s(), self.color))); } + /// Fill a **convex** outline: the tessellator fans a concave one into slivers across its + /// notches, so a concave glyph is drawn as overlapping convex pieces (see [`SELECTION_ARROW`]). fn fill(&self, pts: &[(f32, f32)]) { let v: Vec = pts.iter().map(|(x, y)| self.p(*x, *y)).collect(); self.painter.add(PathShape::convex_polygon(v, self.color, Stroke::NONE)); @@ -206,6 +209,25 @@ impl Pen16<'_> { } } +/// The convex pieces of each concave filled glyph. Pieces overlap rather than abut: abutting +/// antialiased edges would leave a faint seam. +type Pieces = &'static [&'static [(f32, f32)]]; + +/// The Selection tool's arrow pointer (tip upper left, a notch each side of the tail): the head +/// with its left wing, the right wing, and the tail. Only the head reaches the tip: slivers +/// meeting there would antialias into a spike past it. +const SELECTION_ARROW: Pieces = &[ + &[(4.5, 2.0), (4.5, 13.0), (7.2, 10.5), (10.1, 7.39)], + &[(7.5, 9.0), (8.9, 9.9), (12.5, 9.7), (10.1, 7.39)], + &[(7.8, 8.6), (7.2, 10.5), (9.2, 14.6), (10.8, 13.9), (8.9, 9.9)], +]; +/// A loudspeaker: the magnet box, running a little into the cone. +const SPEAKER: Pieces = &[&[(2.0, 6.0), (5.5, 6.0), (5.5, 10.0), (2.0, 10.0)], &[(5.0, 6.0), (9.0, 2.5), (9.0, 13.5), (5.0, 10.0)]]; +/// A four-pointed star: an upright and a level diamond, whose edges cross at the inner corners. +const SPARKLE: Pieces = &[&[(8.0, 1.5), (9.784, 8.0), (8.0, 14.5), (6.216, 8.0)], &[(1.5, 8.0), (8.0, 6.216), (14.5, 8.0), (8.0, 9.784)]]; +#[cfg(test)] +const CONCAVE: [Pieces; 3] = [SELECTION_ARROW, SPEAKER, SPARKLE]; + /// Paint `icon` centred in `rect`. pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { let pen = Pen16 { painter, rect, color, width: 1.25 }; @@ -215,7 +237,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.closed(&[(2.5, 7.5), (8.0, 2.5), (13.5, 7.5), (13.5, 13.5), (2.5, 13.5)]); pen.rect(6.5, 9.5, 9.5, 13.5); } - Selection => pen.fill(&[(4.5, 2.0), (4.5, 13.0), (7.2, 10.5), (9.2, 14.6), (10.8, 13.9), (8.9, 9.9), (12.5, 9.7)]), + Selection => SELECTION_ARROW.iter().for_each(|p| pen.fill(p)), Hand => { pen.line(&[(5.0, 9.0), (5.0, 4.0)]); pen.line(&[(7.3, 8.0), (7.3, 2.6)]); @@ -273,6 +295,18 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); pen.closed(&pts); } + StarFilled => { + // Five triangles fill the concave star without a convex-polygon approximation. + let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); + for pair in pts.windows(2) { + if let [a, b] = pair { + pen.fill(&[(8.0, 8.6), *a, *b]); + } + } + if let (Some(first), Some(last)) = (pts.first(), pts.last()) { + pen.fill(&[(8.0, 8.6), *last, *first]); + } + } Pen => { pen.closed(&[(8.0, 1.8), (12.0, 9.0), (8.0, 14.0), (4.0, 9.0)]); pen.dot(8.0, 8.6, 1.1); @@ -355,7 +389,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.dot(8.0, 8.0, 2.2); } Speaker => { - pen.fill(&[(2.0, 6.0), (5.0, 6.0), (9.0, 2.5), (9.0, 13.5), (5.0, 10.0), (2.0, 10.0)]); + SPEAKER.iter().for_each(|p| pen.fill(p)); pen.arc(9.0, 8.0, 3.0, -50.0, 50.0); pen.arc(9.0, 8.0, 5.5, -50.0, 50.0); } @@ -631,7 +665,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(9.4, 2.8), (6.6, 13.2)]); } Sparkle => { - pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]); + SPARKLE.iter().for_each(|p| pen.fill(p)); } Monitor => { pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2); @@ -737,6 +771,22 @@ fn keyframe_shapes( mod tests { use effectcraft_engine::keyframe::KeyHalf as H; + /// The Selection arrow (and the Speaker and Sparkle) are concave; filled as one polygon they + /// rendered as a broken fan. Each is drawn as convex pieces instead. + #[test] + fn concave_glyphs_are_filled_as_convex_pieces() { + for piece in super::CONCAVE.iter().flat_map(|g| g.iter()) { + let n = piece.len(); + let turns: Vec = (0..n) + .map(|i| { + let (a, b, c) = (piece[i], piece[(i + 1) % n], piece[(i + 2) % n]); + (b.0 - a.0) * (c.1 - b.1) - (b.1 - a.1) * (c.0 - b.0) + }) + .collect(); + assert!(turns.iter().all(|t| *t < 0.0) || turns.iter().all(|t| *t > 0.0), "{piece:?} is not convex: {turns:?}"); + } + } + /// #284: keyframes have no dark border (overlapping keys looked messy): each half is filled /// and edged in the key's own colour. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a6b5501c..ceff436d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod credits; pub mod dock; mod dock_shared; pub mod dock_ui; +mod expression_diagnostics; pub mod frames; pub mod gpu_failure; pub mod header; @@ -94,6 +95,8 @@ pub enum Dialog { UnsavedChanges, /// Delete Project items that compositions use? (`panels::delete_items`). DeleteItems, + /// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`). + GradientEditor, } /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send @@ -323,6 +326,7 @@ pub struct EffectcraftApp { /// Audio panel VU meters. pub meter: audio::Meter, /// Waveform peak summaries per footage item (see `panels::waveform`). + pub(crate) expression_diagnostics: expression_diagnostics::Diagnostics, pub(crate) waveforms: panels::waveform::Cache, /// Last reveal shortcut and when (double-press shortcuts such as LL). pub(crate) last_reveal: Option<(String, f64)>, @@ -408,6 +412,7 @@ impl EffectcraftApp { audio: None, scrub: None, meter: Default::default(), + expression_diagnostics: Default::default(), waveforms: Default::default(), last_reveal: None, applied_prefs: None, @@ -544,6 +549,36 @@ impl EffectcraftApp { self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect() } + /// The open Gradient Editor's draft (`panels::gradient_editor`): what OK would set. + pub fn gradient_draft(&self) -> &panels::gradient_editor::Draft { + &self.dialog_state.gradient + } + + /// The open Gradient Editor's draft, to edit (tests and automation; the editor's own fields + /// do the same). + pub fn gradient_draft_mut(&mut self) -> &mut panels::gradient_editor::Draft { + &mut self.dialog_state.gradient + } + + /// Feed this frame's share of the queued synthetic input (control channel `ui.click`, + /// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves + /// → release as a real gesture. + pub fn feed_synthetic(&mut self, raw_input: &mut egui::RawInput) { + if !self.synthetic.is_empty() { + // Pointer events go one per frame so egui sees press → moves → release as a real drag. + let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); + let n = if pointer(&self.synthetic[0]) { + 1 + } else { + self.synthetic + .iter() + .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) + .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) + }; + raw_input.events.extend(self.synthetic.drain(..n)); + } + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ . pub fn show_panel(&mut self, p: PanelKind) { @@ -810,27 +845,26 @@ impl EffectcraftApp { self.viewer_image.clone() } + /// The viewer's Resolution: the active composition's (saved with the project). + pub fn viewer_res(&self) -> state::Resolution { + self.session.active_comp().map(|c| c.resolution).unwrap_or_default() + } + /// The render scale used by the viewer right now. pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 { // The preview's Resolution (when not Auto) while it plays. if self.playback.playing && let Some(f) = self.playback.res { - let r = match f { - 1 => state::Resolution::Full, - 2 => state::Resolution::Half, - 3 => state::Resolution::Third, - 4 => state::Resolution::Quarter, - n => state::Resolution::Custom(n.min(40) as u8), - }; - return r.scale(zoom, ppp); + return state::Resolution::from_factor(f.into()).scale(zoom, ppp); } // Auto follows the zoom alike while playing and paused, so frames cached while scrubbing // play back, and frames cached by a preview stay on screen when it stops. - let full = self.ui.viewer.res.scale(zoom, ppp); + let res = self.viewer_res(); + let full = res.scale(zoom, ppp); // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting || self.ui.viewer.property_interacting); - if k < 1.0 && self.ui.viewer.res == state::Resolution::Auto { + if k < 1.0 && res.is_auto() { return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); } full @@ -1140,6 +1174,9 @@ impl EffectcraftApp { }; let mut queued = self.frames.inflight(); let series = self.frame_series(cid, scale); + // The preview's position orders prefetch and eviction (#595). + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + self.frames.set_playhead(playhead); if self.playback.caching && let Some(p) = plan { @@ -1180,7 +1217,10 @@ impl EffectcraftApp { return; } let step = plan.map_or(1, |p| p.step); - for k in 0..ahead * 3 { + // Never further ahead than the RAM preview holds: those frames would evict the ones the + // preview shows first, or be evicted before it gets there (#595). + let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1); + for k in 0..window { if queued >= ahead as usize || !video { break; } @@ -1542,6 +1582,11 @@ impl EffectcraftApp { } } effectcraft_engine::Event::PurgeCaches => self.frames.clear(), + effectcraft_engine::Event::CopyImage { width, height, rgba } => { + if let Err(e) = copy_image(ctx, width, height, &rgba) { + self.ui.status = e; + } + } } } } @@ -1591,6 +1636,7 @@ impl EffectcraftApp { self.session.poll_camera(true); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } + self.run_script_tasks(&ctx); self.apply_prefs(&ctx); self.handle_events(&ctx); panels::unsaved::on_close_requested(self, &ctx); @@ -1702,6 +1748,24 @@ impl EffectcraftApp { } impl EffectcraftApp { + /// Scripts' `app.scheduleTask` tasks that are due (repeating ones keep a ScriptUI panel + /// fresh): errors as a toast, the last line printed in the status bar, and a repaint when + /// the next one is due. + fn run_script_tasks(&mut self, ctx: &egui::Context) { + if self.session.script_ui.task_hosts.is_empty() { + return; + } + let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return }; + if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) { + self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time))); + } else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) { + self.ui.status = line.to_string(); + } + if let Some(next) = r["next"].as_u64() { + ctx.request_repaint_after(std::time::Duration::from_millis(next)); + } + } + /// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save). fn tick_autosave(&mut self, ctx: &egui::Context) { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); @@ -1777,19 +1841,7 @@ impl eframe::App for EffectcraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { let injected = !self.synthetic.is_empty(); - if injected { - // Pointer events go one per frame so egui sees press → moves → release as a real drag. - let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); - let n = if pointer(&self.synthetic[0]) { - 1 - } else { - self.synthetic - .iter() - .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) - .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) - }; - raw_input.events.extend(self.synthetic.drain(..n)); - } + self.feed_synthetic(raw_input); if pointer_wrap::prepare_input(ctx, raw_input, injected) { self.session.history.merge_key = None; } @@ -1868,6 +1920,18 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale app.advance_playback(ctx, scale); } +/// Put `width` × `height` straight-alpha RGBA8 pixels on the system clipboard (Composition ▸ Copy +/// Frame to Clipboard). The pixel count is checked first: egui's image constructor panics on a +/// size that doesn't match its buffer. +fn copy_image(ctx: &egui::Context, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> { + let (w, h) = (width as usize, height as usize); + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return Err(format!("cannot copy a malformed {width}×{height} image to the clipboard")); + } + ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba)); + Ok(()) +} + /// Maximize a first window that doesn't fit the screen, which fits it to the space beside the /// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's /// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the @@ -1901,6 +1965,55 @@ mod fit_window_tests { } } +#[cfg(test)] +mod copy_frame_tests { + use super::*; + + fn copied(output: &egui::FullOutput) -> Vec<&egui::ColorImage> { + output + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyImage(image) => Some(image), + _ => None, + }) + .collect() + } + + /// Composition ▸ Copy Frame to Clipboard puts the rendered frame on the system clipboard. + #[test] + fn copy_frame_puts_the_frame_on_the_clipboard() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Copy", "width": 8, "height": 6, "duration": 1})).unwrap(); + session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let mut app = EffectcraftApp::new(session); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::menus::invoke(&mut app, ui.ctx(), "comp.copyFrame", json!({})).unwrap(); + app.handle_events(ui.ctx()); + }); + output.textures_delta.clear(); + let images = copied(&output); + assert_eq!(images.len(), 1); + assert_eq!(images[0].size, [8, 6]); + assert!(images[0].pixels.iter().all(|p| *p == egui::Color32::from_rgb(255, 0, 0))); + } + + /// A malformed image never reaches egui's panicking constructor. + #[test] + fn a_malformed_image_is_an_error_not_a_crash() { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(copy_image(ui.ctx(), 4, 4, &[0; 10]).is_err()); + assert!(copy_image(ui.ctx(), 0, 4, &[]).is_err()); + assert!(copy_image(ui.ctx(), u32::MAX, u32::MAX, &[0; 4]).is_err()); + }); + output.textures_delta.clear(); + assert!(copied(&output).is_empty()); + } +} + #[cfg(test)] mod gpu_failure_tests { use super::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..b10b0e31 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -35,7 +35,6 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), - uic!("view.res.auto", "Resolution: Auto", [], None), uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")), uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")), uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")), @@ -127,6 +126,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), + uic!("window.panelWindow", "Move Panel to New Window", [], None), ]; pub fn panel_command_id(p: PanelKind) -> String { @@ -273,6 +273,12 @@ fn no_params(p: &Value) -> bool { /// Execute a UI or engine command by id. pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + // Layer ▸ New ▸ Text (menu, shortcut, context menus) puts an insertion point in the centre of + // the comp, as After Effects does, so typing goes into Source Text rather than running + // shortcuts (#866). `engine.execute` keeps the headless default. + if id == "layer.newText" && no_params(¶ms) { + return run_engine(app, ctx, id, json!({"text": "", "edit": true})); + } if no_params(¶ms) && app.ui.focused == PanelKind::Project { match id { "edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})), @@ -291,6 +297,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After + // Effects 26.3). Copying in the app replaces the system clipboard with a note naming what + // was copied, so SVG there was copied after anything copied in the app. + if id == "edit.paste" && no_params(¶ms) && app.session.state.text_edit.is_none() { + let text = ctx.data_mut(|d| d.remove_temp::(system_paste_id())).or_else(|| app.hooks.clipboard_text.as_ref().and_then(|f| f())); + if let Some(svg) = text.filter(|t| effectcraft_engine::vector::is_svg_text(t)) { + return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg})); + } + } // Clear follows panel focus, including the Edit menu and keyboard shortcut. if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { // Through `invoke`, so deleting items that compositions use still asks first (M3.16). @@ -442,6 +457,22 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } return Ok(Value::Null); } + // Multiple monitors: {panel, window?: bool} moves a panel into its own native window (or + // back into the main window; no `window` toggles). + if id == "window.panelWindow" { + // A panel name ("Info", "Effect Controls") or its serialized form ({"Viewer": 2}). + let value = params.get("panel").ok_or("panelWindow: need `panel`")?; + let panel = match value.as_str() { + Some(name) => PanelKind::from_name(name).ok_or(format!("unknown panel `{name}`"))?, + None => serde_json::from_value::(value.clone()).map_err(|_| "panelWindow: unknown `panel`".to_string())?, + }; + let window = match params.get("window") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_bool().ok_or("panelWindow: `window` must be true or false")?), + }; + let window = app.set_panel_window(ctx, panel, window)?; + return Ok(json!({"window": window})); + } // A UI-only command (not in the engine's menus), so `frontend` gets it from here. if id == "window.maximizeApp" { return frontend(app, ctx, id, params); @@ -497,10 +528,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::markers_ui::open_dialog(app, crate::panels::markers_ui::MarkerRef { layer, index })?; return Ok(json!({"dialog": id})); } - if id == "view.res.auto" { - app.ui.viewer.res = Resolution::Auto; - return Ok(Value::Null); - } + if id == "view.theme.toggle" { app.cycle_appearance(ctx); return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode})); @@ -525,11 +553,23 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.stop(); return Ok(Value::Null); } + // The focused Layer panel has its own magnification; otherwise the Composition viewer. + "view.fit" if app.ui.focused == PanelKind::Layer => { + app.ui.layer_zoom = None; + app.ui.layer_pan = [0.0, 0.0]; + return Ok(Value::Null); + } "view.fit" => { app.ui.viewer.zoom = None; + app.ui.viewer.fit_up_to_100 = false; app.ui.viewer.pan = [0.0, 0.0]; return Ok(Value::Null); } + "view.actualSize" if app.ui.focused == PanelKind::Layer => { + app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); + app.ui.layer_pan = [0.0, 0.0]; + return Ok(Value::Null); + } "view.actualSize" => { app.ui.viewer.zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.viewer.pan = [0.0, 0.0]; @@ -758,6 +798,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, + // A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor. + "prop.editGradient" => crate::panels::gradient_editor::open(app, &p)?, "window.scriptPanel" => { let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32; app.show_panel(PanelKind::ScriptPanel(id)); @@ -773,12 +815,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "app.gpuInfo" => { - let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); crate::panels::dialogs::info( app, "GPU Information", &format!( - "Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.", + "{}Layer cache: {} MB • Preview cache: {} MB.", + effectcraft_engine::sysinfo::gpu_summary(&app.session), app.session.layer_cache.budget() >> 20, app.frames.budget() >> 20 ), @@ -859,44 +901,35 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } r } + // Zoom In / Zoom Out act on the focused viewer: the Layer panel when it has focus, else + // the Composition viewer. + "view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => { + let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx)); + // The Layer panel zooms in points (pixels-per-point 1). + let next = zoom_step(cur, 1.0, id == "view.zoomIn"); + app.ui.layer_zoom = Some(next); + json!({"zoom": next, "panel": "layer"}) + } "view.zoomIn" | "view.zoomOut" => { + let ppp = ctx.pixels_per_point(); let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)); - const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; - let next = if id == "view.zoomIn" { - STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0) - } else { - STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015) - }; + let next = zoom_step(cur, ppp, id == "view.zoomIn"); v.zoom = Some(next); json!({"zoom": next}) } - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - let r = id.trim_start_matches("view.res."); - v.res = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)).ok_or("unknown resolution")?; - Value::Null - } + // View ▸ Resolution ▸ Custom without a factor (the engine sets the comp's resolution). "view.res.custom" => { - match p.get("factor").and_then(Value::as_u64) { - Some(n) => { - v.res = match n.clamp(1, 40) { - 1 => Resolution::Full, - n => Resolution::Custom(n as u8), - }; - } - None => { - let cur = match v.res { - Resolution::Custom(n) => n as f64, - _ => 2.0, - }; - crate::panels::dialogs::form( - app, - "Custom Resolution", - "view.res.custom", - json!({}), - vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], - ); - } - } + let cur = match app.viewer_res() { + Resolution::Custom(n) => f64::from(n), + _ => 2.0, + }; + crate::panels::dialogs::form( + app, + "Custom Resolution", + "view.res.custom", + json!({}), + vec![crate::panels::dialogs::Field::num("factor", "Render every n-th pixel", cur)], + ); Value::Null } "view.rulers" => toggle(&mut v.rulers, &p), @@ -1085,6 +1118,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } "layer.openLayer" => { let id = p.get("layer").and_then(Value::as_u64).or_else(|| app.session.state.selected_layers.first().map(|l| l.0)); + // Another layer opens at Fit. + if id != app.ui.layer_panel { + app.ui.layer_zoom = None; + app.ui.layer_pan = [0.0, 0.0]; + } app.ui.layer_panel = id; app.ui.layer_view = None; app.show_panel(PanelKind::Layer); @@ -1213,7 +1251,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1225,8 +1263,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option Option bool { + if app.ui.floating.iter().any(|f| f.panels.get(f.active) == Some(&p)) { + return true; + } + match app.ui.maximized.filter(|m| app.ui.dock.contains(*m)) { + Some(m) => m == p, + None => app.ui.dock.is_visible(p), + } +} + /// Check-mark state of frontend toggles (engine state is answered by the engine). pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option { let v = &app.ui.viewer; @@ -1375,10 +1425,9 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option "view.layerControls" => Some(v.show_layer_controls), "playback.cacheWhenIdle" => Some(app.ui.cache_when_idle), "playback.audio" => Some(app.session.prefs.preview.active().include_audio), - "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { - Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res."))) - } "window.workspace" => Some(pstr("name").is_some_and(|n| n == app.ui.workspace)), + // The Tools panel is the header toolbar: always on screen. + "window.panel" => Some(pstr("panel").is_some_and(|n| n.eq_ignore_ascii_case("tools") || PanelKind::from_name(n).is_some_and(|p| panel_shown(app, p)))), "view.panelBackground" if e.params.get("pick").is_none() => { let cur = match v.pasteboard { None => "mediumGray", @@ -1537,8 +1586,8 @@ pub type Binding = (egui::Modifiers, egui::Key, String, Value); pub fn bindings(session: &effectcraft_engine::Session) -> Vec { let mut v: Vec = Vec::new(); for (sc, b) in session.shortcuts().bindings() { - // Off macOS `Cmd` is Ctrl, so a Ctrl+Cmd shortcut (Enter Full Screen's Ctrl+Cmd+F) would - // steal the plain Cmd one (Find's Cmd+F): such macOS-only shortcuts aren't bound there. + // Off macOS `Cmd` is Ctrl, so a custom Ctrl+Cmd shortcut would + // steal the plain Cmd one: such macOS-only shortcuts aren't bound there. if !cfg!(target_os = "macos") && sc.split('+').any(|p| p == "Ctrl") && sc.split('+').any(|p| p == "Cmd") { continue; } @@ -1561,6 +1610,11 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool { /// Set (egui temp data) from a plain Spacebar press until its release while a tap would still /// run the Spacebar shortcut. +/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs. +fn system_paste_id() -> egui::Id { + egui::Id::new("menus-system-paste") +} + fn space_tap_id() -> egui::Id { egui::Id::new("spacebar-tap") } @@ -1606,7 +1660,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only let start = modifiers_at_start(ctx); // Dialog cancellation owns Escape even when a text field has keyboard focus. if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) { + // A popup or colour picker takes Escape first (the picker goes back to its original colour). + if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) || crate::color_picker::open_recently(ctx) { return; } ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); @@ -1641,6 +1696,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only // Text editing in the viewer takes the clipboard events itself. let clipboard = app.session.state.text_edit.is_none(); let project = app.ui.focused == PanelKind::Project; + // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. + let pasted = + if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, @@ -1756,9 +1814,15 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) { let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() }); + if id == "edit.paste" + && let Some(t) = &pasted + { + ctx.data_mut(|d| d.insert_temp(system_paste_id(), t.clone())); + } if let Err(e) = invoke(app, ctx, &id, params) { app.ui.status = e; } + ctx.data_mut(|d| d.remove_temp::(system_paste_id())); } } } @@ -1770,7 +1834,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let mut nav = crate::menu_keys::Nav::default(); ui.horizontal_centered(|ui| { ui.add_space(6.0); - egui::MenuBar::new().ui(ui, |ui| { + egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| { for node in effectcraft_engine::menus::menu_bar() { if let MenuNode::Submenu { label, children } = node { let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { @@ -1932,10 +1996,38 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::R ui.add_enabled(enabled, b) } +/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so +/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is +/// in points per comp pixel like the result (#882). +fn zoom_step(cur: f32, ppp: f32, zoom_in: bool) -> f32 { + const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let shown = cur * ppp; + let next = if zoom_in { + STEPS.iter().copied().find(|s| *s > shown + 1e-3).unwrap_or(32.0) + } else { + STEPS.iter().rev().copied().find(|s| *s < shown - 1e-3).unwrap_or(0.015) + }; + next / ppp +} + #[cfg(test)] mod tests { use super::*; + /// #882: at 125 % / 150 % display scaling Zoom In reaches 100 % (one comp pixel per screen + /// pixel), not 100 % in points (125 % / 150 % on screen). + #[test] + fn zoom_steps_land_on_whole_screen_magnifications() { + for ppp in [1.0f32, 1.25, 1.5, 2.0] { + let at_100 = zoom_step(0.7 / ppp, ppp, true); + assert!((at_100 * ppp - 1.0).abs() < 1e-6, "ppp {ppp}: {}", at_100 * ppp); + assert!((zoom_step(at_100, ppp, true) * ppp - 1.5).abs() < 1e-6); + assert!((zoom_step(at_100, ppp, false) * ppp - 0.66).abs() < 1e-6); + } + assert!(zoom_step(1.0, 0.0, true).is_finite()); + } + #[test] fn shortcut_passes_share_frame_start_modifiers() { let ctx = egui::Context::default(); @@ -1971,6 +2063,18 @@ mod tests { } } + #[test] + fn full_screen_default_is_platform_specific() { + let app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let full_screen = app.session.shortcuts().shortcut_of("view.fullScreen", &Value::Null); + if cfg!(target_os = "macos") { + assert_eq!(full_screen, Some("Ctrl+Cmd+F")); + } else { + assert_eq!(full_screen, None); + assert_eq!(app.session.shortcuts().shortcut_of("window.maximizeApp", &Value::Null), Some("Cmd+\\")); + } + } + #[test] fn ui_and_menu_shortcuts_do_not_collide() { let app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e727b9f9..5d0a3c53 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -196,6 +196,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Servicios" } else if crate::i18n::brazilian_portuguese(app) { "Serviços" + } else if crate::i18n::czech(app) { + "Služby" } else { "Services" } @@ -314,8 +316,9 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); - v.res.label().hash(&mut h); (app.ui.cache_when_idle, app.session.prefs.preview.active().include_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); + // Window menu check marks: the panels on screen. + crate::dock::PanelKind::ALL.iter().filter(|p| crate::menus::panel_shown(app, **p)).for_each(|p| p.hash(&mut h)); h.finish() } @@ -345,6 +348,17 @@ fn text_field_event(command: &str, clipboard: Option) -> Option Result { let item = menu.find(id).ok_or_else(|| format!("no native menu item `{id}`"))?; let (command, params) = (item.command.clone(), item.params.clone()); + // The menu bar takes Cmd+D and Cmd+R before the window sees them: while a confirmation that + // answers to the key is open, hand it back. Choosing the item with the mouse (no Cmd held) + // still runs it. + if let Some(key) = crate::panels::dialogs::command_key(app) + && item.accelerator.as_deref().is_some_and(|a| ["Cmd+", "Ctrl+"].iter().any(|m| a.strip_prefix(m) == Some(key.name()))) + && ctx.input(|i| i.modifiers.command) + { + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + ctx.request_repaint(); + return Ok(Value::Null); + } if ctx.egui_wants_keyboard_input() { let clip = if command == "edit.paste" { app.hooks.clipboard_text.as_ref().and_then(|f| f()) } else { None }; if let Some(events) = text_field_event(&command, clip) { @@ -445,6 +459,27 @@ mod tests { assert_eq!(translated, ["Служби", "Приховати EffectCraft", "Приховати решту", "Показати все"]); } + #[test] + fn czech_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); + } + #[test] fn hide_items_are_predefined_on_macos() { let spec = build(&app()); @@ -469,6 +504,32 @@ mod tests { } } + #[test] + fn a_confirmation_gets_its_command_key_back_from_the_menu_bar() { + let mut app = app(); + let spec = build(&app); + let dup = spec.items().into_iter().find(|i| i.command == "edit.duplicate").map(|i| i.id.clone()).unwrap(); + let ctx = egui::Context::default(); + app.dialog_state.delete_items.command = Some((json!({"items": []}), json!({"items": 1, "layers": 1, "comps": 1}))); + app.dialog = Some(crate::Dialog::DeleteItems); + // Chosen with the mouse, Duplicate still runs. + ctx.run_ui(Default::default(), |ui| { + let _ = activate(&mut app, ui.ctx(), &spec, &dup); + }) + .textures_delta + .clear(); + assert!(app.synthetic.is_empty()); + // Cmd+D goes to the prompt instead. + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + ctx.run_ui(raw, |ui| { + let _ = activate(&mut app, ui.ctx(), &spec, &dup); + }) + .textures_delta + .clear(); + assert!(matches!(app.synthetic.as_slice(), [egui::Event::Key { key: egui::Key::D, pressed: true, .. }])); + } + #[test] fn accelerators_convert() { assert_eq!(accelerator("Cmd+Shift+S").as_deref(), Some("Cmd+Shift+S")); @@ -495,6 +556,28 @@ mod tests { } } + /// Window menu entries are checked while their panel is on screen. + #[test] + fn window_menu_checks_the_panels_on_screen() { + use crate::dock::PanelKind; + let mut app = app(); + let checked = |app: &EffectcraftApp, panel: &str| { + build(app).items().into_iter().find(|i| i.command == "window.panel" && i.params["panel"] == panel).and_then(|i| i.checked) + }; + assert_eq!(checked(&app, "timeline"), Some(true)); + assert_eq!(checked(&app, "tools"), Some(true)); + assert_eq!(checked(&app, "renderQueue"), Some(false)); + let k0 = state_key(&app); + app.show_panel(PanelKind::RenderQueue); + assert_ne!(state_key(&app), k0); + // A tab behind another one, a closed panel and panels under a maximized one are not shown. + assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(true), Some(false))); + app.ui.dock.close(PanelKind::RenderQueue); + assert_eq!((checked(&app, "renderQueue"), checked(&app, "timeline")), (Some(false), Some(true))); + app.ui.maximized = Some(PanelKind::Timeline); + assert_eq!((checked(&app, "timeline"), checked(&app, "project")), (Some(true), Some(false))); + } + #[test] fn state_changes_flow_into_the_spec() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 71cea155..2bccfae0 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -296,7 +296,7 @@ pub fn viewer_hook( ui.data_mut(|d| d.insert_temp(cid, pts)); } let base = json!({"layer": a.layer.0, "effect": a.uid}); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let pts: Vec = ui.data(|d| d.get_temp(cid)).unwrap_or_default(); let with = |kind: Option<&str>, multiple: bool| { let mut q = base.clone(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index ca003c3b..2cd19df5 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -3,6 +3,8 @@ //! (`dialog.comp.`). use crate::i18n::{tr, tr_args}; +use crate::icons::Icon; +use effectcraft_engine::prefs::CompPreset; use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode}; use egui::{Color32, vec2}; use serde_json::json; @@ -96,6 +98,8 @@ pub struct CompDraft { /// Timecode text being edited (start, duration). pub start_tc: Option, pub dur_tc: Option, + /// The name being typed for Save Preset (the name row shows while `Some`). + pub preset_name: Option, } impl Default for CompDraft { @@ -121,6 +125,7 @@ impl Default for CompDraft { tab: Tab::Basic, start_tc: None, dur_tc: None, + preset_name: None, } } } @@ -171,12 +176,84 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, r r } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. -pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registry) -> Option { +fn name_focus_id() -> egui::Id { + egui::Id::new("comp-preset-name-focus") +} + +/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while +/// `d.preset_name` is `Some`): OK or Enter saves the draft's frame size, pixel aspect and frame +/// rate under the name, replacing a saved preset with that name; Cancel or Escape closes it. +pub fn choose_name( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) { + let Some(mut name) = d.preset_name.take() else { return }; + let mut keep = true; + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(ui.available_width())); + auto.add("dialog.comp.presetName", r.rect, "Preset Name"); + if ui.data_mut(|m| m.remove_temp::(name_focus_id())).unwrap_or(false) { + r.request_focus(); + } + let trimmed = name.trim(); + let builtin = PRESETS.iter().any(|p| p.0 == trimmed); + let exists = custom.iter().any(|q| q.name == trimmed); + let ok = !trimmed.is_empty() && !builtin && trimmed.chars().count() <= CompPreset::MAX_NAME; + let note = if builtin { + tr("A built-in preset has this name") + } else if exists { + tr("Replaces your preset with this name") + } else { + "" + }; + ui.add_space(4.0); + ui.label(egui::RichText::new(note).color(Color32::GRAY)); + ui.add_space(8.0); + let mut save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add_enabled(ok, egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + auto.add("dialog.comp.presetNameSave", b.rect, "OK"); + save |= b.clicked(); + let c = ui.button(tr("Cancel")); + auto.add("dialog.comp.presetNameCancel", c.rect, "Cancel"); + cancel |= c.clicked(); + }); + if ok && save { + cmds.push(("comp.savePreset", json!({"name": trimmed, "width": d.width, "height": d.height, "pixelAspect": d.pixel_aspect, "frameRate": d.fps}))); + keep = false; + } + if cancel { + keep = false; + } + if keep { + d.preset_name = Some(name); + } +} + +/// Whether the draft's frame size, pixel aspect and frame rate are those of a preset. +fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { + w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) -> Option { let mut out = None; ui.horizontal(|ui| { ui.label(tr("Composition Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); auto.add("dialog.comp.name", r.rect, "Composition Name"); }); ui.add_space(8.0); @@ -195,19 +272,48 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); - let current = PRESETS - .iter() - .find(|(_, w, h, par, f)| *w == d.width && *h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (f - d.fps).abs() < 1e-3) - .map(|p| p.0) + // The user's own presets win over a built-in one with the same values. + let own = custom.iter().find(|q| matches(d, q.width, q.height, q.pixel_aspect, q.frame_rate)); + let current = own + .map(|q| q.name.as_str()) + .or_else(|| PRESETS.iter().find(|(_, w, h, par, f)| matches(d, *w, *h, *par, *f)).map(|p| p.0)) .unwrap_or("Custom"); - let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { - for (name, w, h, par, f) in PRESETS { - if ui.selectable_label(*name == current, *name).clicked() { - (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + ui.horizontal(|ui| { + let r = egui::ComboBox::from_id_salt("comp-preset").width(300.0).selected_text(current).show_ui(ui, |ui| { + // Your own presets first, then the built-in ones. + for q in custom { + if ui.selectable_label(q.name == current, &q.name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (q.width, q.height, q.pixel_aspect, q.frame_rate); + } + } + if !custom.is_empty() { + ui.separator(); + } + for (name, w, h, par, f) in PRESETS { + if ui.selectable_label(*name == current, *name).clicked() { + (d.width, d.height, d.pixel_aspect, d.fps) = (*w, *h, *par, *f); + } + } + }); + auto.add("dialog.comp.preset", r.response.rect, "Preset"); + let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save")) + .on_hover_text(tr("Save Preset…")); + auto.add("dialog.comp.presetSave", save_r, "Save Preset"); + if save.clicked() { + d.preset_name = Some(own.map(|q| q.name.clone()).unwrap_or_default()); + ui.data_mut(|m| m.insert_temp(name_focus_id(), true)); + } + if let Some(q) = own { + let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover()); + let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete")) + .on_hover_text(tr("Delete Preset")); + auto.add("dialog.comp.presetDelete", del_r, "Delete Preset"); + if del.clicked() { + cmds.push(("comp.deletePreset", json!({"name": q.name}))); } } }); - auto.add("dialog.comp.preset", r.response.rect, "Preset"); ui.end_row(); ui.label(tr("Width:")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/delete_items.rs b/crates/ui-egui/src/panels/delete_items.rs index ca07fd18..b093fb9a 100644 --- a/crates/ui-egui/src/panels/delete_items.rs +++ b/crates/ui-egui/src/panels/delete_items.rs @@ -74,6 +74,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(false); } + // Cmd+D (Ctrl elsewhere) is Delete. + if answer.is_none() && super::dialogs::ui_command_key(ctx, egui::Key::D) { + answer = Some(true); + } let Some(delete) = answer else { return }; app.dialog = None; app.dialog_state.delete_items.command = None; @@ -89,3 +93,22 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { pub fn delete_confirmed(app: &mut EffectcraftApp, ctx: &egui::Context, params: Value) -> Result { crate::menus::run_engine(app, ctx, "project.delete", params) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cmd_d_is_delete_and_enter_is_not() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.dialog_state.delete_items.command = Some((json!({"items": []}), json!({"items": 1, "layers": 1, "comps": 1}))); + app.dialog = Some(Dialog::DeleteItems); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert_eq!(app.dialog, Some(Dialog::DeleteItems)); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::D, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none() && app.dialog_state.delete_items.command.is_none(), "Cmd+D answered Delete"); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 81fa6bab..e95a667e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -60,12 +60,15 @@ pub struct DialogState { pub unsaved: super::unsaved::Pending, /// Deleting Project items in use: the deletion waiting on the prompt. pub delete_items: super::delete_items::Pending, + /// The Gradient Editor's draft and the property it edits. + pub gradient: super::gradient_editor::Draft, } pub fn open_new_comp(app: &mut EffectcraftApp) { let n = app.session.project.comps().count() + 1; let last = app.dialog_state.last_new_comp.clone().unwrap_or_default(); - app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, ..last }; + app.dialog_state.comp = + CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; app.dialog = Some(Dialog::NewComp); } @@ -149,9 +152,32 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value) } } +/// The room a modal dialog may take: the window less a margin. +fn modal_space(ctx: &egui::Context) -> egui::Vec2 { + (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0)) +} + pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { - let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0)); - let size = size.min(available); + modal_with_scroll(ctx, title, size, t, false, body); +} + +pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { + modal_with_scroll(ctx, title, size, t, true, body); +} + +fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit(ctx, title, size, t, |ui, _| { + let scroll = if vertical_only { egui::ScrollArea::vertical() } else { egui::ScrollArea::both() }; + scroll.max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + +/// [`modal`] without its scroll area, for a dialog that keeps its buttons in view and scrolls +/// only its content: `body` gets the height left under the title, with `size` kept within the +/// window. +pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + let size = size.min(modal_space(ctx)); egui::Modal::new(egui::Id::new(("modal", title))) .backdrop_color(Color32::from_black_alpha(120)) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) @@ -161,7 +187,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke // The id stays keyed on the untranslated source, so switching language cannot move it. ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); ui.add_space(10.0); - egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body); + let title_h = ui.cursor().top() - ui.min_rect().top(); + body(ui, (size.y - 36.0 - title_h).max(40.0)); }); } @@ -193,6 +220,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t), Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t), Dialog::DeleteItems => super::delete_items::show(app, ctx, &t), + Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t), } } @@ -311,10 +339,24 @@ fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) { let mut d = app.dialog_state.comp.clone(); let mut result = None; + let custom = app.session.prefs.comp_presets.clone(); + let mut cmds = Vec::new(); modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto); + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); }); - if result.is_none() && ui_enter(ctx) { + if d.preset_name.is_some() { + // Save Preset asks for the name in its own window over the dialog, as After Effects does. + result = None; + modal(ctx, "Choose Name", vec2(360.0, 150.0), t, |ui| super::comp_settings::choose_name(ui, &mut d, t, &mut app.auto, &custom, &mut cmds)); + } + // Enter that saved a preset name (or while one is typed) doesn't also press OK. + let preset_busy = !cmds.is_empty() || d.preset_name.is_some(); + for (id, params) in cmds { + if let Err(e) = app.session.execute(id, params) { + app.ui.status = e.to_string(); + } + } + if result.is_none() && !preset_busy && ui_enter(ctx) { result = Some(super::comp_settings::params(&d)); } app.dialog_state.comp = d; @@ -334,6 +376,35 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } +/// One frame of the open dialog with `key` pressed. +#[cfg(test)] +pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + let mut out = ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + show(app, ui.ctx()); + }); + out.textures_delta.clear(); +} + +/// The letter that answers the open confirmation with Cmd (Ctrl elsewhere): Cmd+D is Delete in +/// Delete Items, Cmd+R is Revert in Revert Project. The native menu bar hands these keys back +/// while the prompt is open ([`crate::native_menu::activate`]). +pub(crate) fn command_key(app: &EffectcraftApp) -> Option { + match app.dialog? { + Dialog::DeleteItems => Some(egui::Key::D), + Dialog::UnsavedChanges if super::unsaved::is_revert(app) => Some(egui::Key::R), + _ => None, + } +} + +/// Cmd+`key` (Ctrl elsewhere) pressed, also while a text field has focus: ask before the dialog +/// is drawn, so the field does not edit with the key first. +pub(crate) fn ui_command_key(ctx: &egui::Context, key: egui::Key) -> bool { + ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, key)) +} + pub(crate) fn ui_enter(ctx: &egui::Context) -> bool { !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) } @@ -353,6 +424,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &name); auto.add("dialog.solid.name", r.rect, "Name"); }); ui.add_space(8.0); @@ -628,6 +700,32 @@ mod tests { assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default"); } + #[test] + fn a_dialog_opens_with_its_name_field_focused_and_selected() { + let (mut app, ctx) = app(); + let selected = |ctx: &egui::Context| { + let id = ctx.memory(|m| m.focused())?; + egui::TextEdit::load_state(ctx, id)?.cursor.char_range().map(|r| r.as_sorted_char_range()).map(|r| r.start.0..r.end.0) + }; + open_new_comp(&mut app); + let name = app.dialog_state.comp.name.clone(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..name.chars().count()), "typing replaces “{name}”"); + // Afterwards the focus is the user's: it is not taken back. + ctx.memory_mut(|m| m.stop_text_input()); + frame(&mut app, &ctx); + assert!(ctx.memory(|m| m.focused()).is_none()); + // Closed and opened again: focused again. + app.dialog = None; + frame(&mut app, &ctx); + frame(&mut app, &ctx); + open_new_solid(&mut app).unwrap(); + frame(&mut app, &ctx); + frame(&mut app, &ctx); + assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count())); + } + #[test] fn composition_settings_register_every_control_and_new_comp_remembers() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index afc0fc8a..a1a67bc6 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -173,7 +173,8 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Preset")); let focal = focal_for_zoom(w, d.zoom); @@ -258,7 +259,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); - ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Light Type")); egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index a63b5a83..cc960175 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -121,7 +121,7 @@ pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json:: /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. -pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { +pub(crate) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { let kid = id.with("gesture"); let n: u64 = if started { let n = ui.data(|d| d.get_temp::(kid)).unwrap_or(0) + 1; @@ -194,6 +194,7 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p app.ui.fx_pick = None; } else { app.ui.fx_pick = Some(pick); + app.ui.text_color_pick = None; app.ui.status = if kind == "point" { format!("Click in the Composition panel to set {}", prop.name) } else { @@ -1158,9 +1159,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); - let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); - app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); + let rename_id = egui::Id::new(("ec-rename", g.uid)); + let editing: Option = ctx.data(|d| d.get_temp(rename_id)); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); + if let Some(mut value) = editing.as_ref().cloned() { + let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); + // Focus the field the frame it first exists: focusing an id not yet on screen leaves + // the accessibility tree with a focus it doesn't contain. + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { + crate::widgets::select_all(&ctx, response.id, &value); + response.request_focus(); + } + app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); + let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if escape || commit { + ctx.data_mut(|d| d.remove::(rename_id)); + if !escape && !value.trim().is_empty() && value != g.name { + actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, value)); + } + } else { + let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); + app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); + } let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click()); bp.text(reset.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim }); app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset"); @@ -1183,8 +1210,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hresp = ui.interact( Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::new(("ec-hdr", g.uid)), - Sense::click_and_drag(), + if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() }, ); + if hresp.double_clicked() { + ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); + } hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name)); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { @@ -1202,7 +1233,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if hresp.drag_started() { ctx.data_mut(|d| d.insert_temp(drag_id, g.uid)); } - hresp.context_menu(|ui| { + crate::widgets::context_menu(&hresp, |ui| { let lid = layer.id.0; let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| { if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { @@ -1213,6 +1244,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid})); item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]})); item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid})); + if ui.button(crate::i18n::tr("Rename")).clicked() { + ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); + ui.close(); + } ui.separator(); item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid})); item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid})); @@ -1285,7 +1321,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let open = ui.input(|i| i.pointer.secondary_clicked()) && ui.rect_contains_pointer(body) && (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id)); - egui::Popup::context_menu(&bg).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { + egui::Popup::context_menu(&bg).style(crate::widgets::menu_style).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| { ui.set_min_width(200.0); actions.extend(crate::menus::menu_contents(app, ui, "Effect")); }); @@ -1425,7 +1461,50 @@ pub fn viewer_hook( app.ui.status.clear(); } } - let picking = app.ui.fx_pick.is_some(); + if let Some(pick) = app.ui.text_color_pick.clone() { + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::new("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } + } + let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { // ⊕ controls for the point parameters of the selected effects. @@ -1454,6 +1533,9 @@ pub fn viewer_hook( if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) { super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions); } + if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::MESH_WARP) { + super::fx_editors::mesh_warp_overlay(app, ui, painter, map, ectx, layer, g, &fm, &mut actions); + } let scope = FxScope { effect: &g.match_id, root: g, path: String::new() }; let mut pts = Vec::new(); visible_points(g, &scope, layer, ectx, 0, &mut pts); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index e60354b1..da6812be 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -1,7 +1,7 @@ //! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an -//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and -//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the +//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition +//! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the //! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies //! it there. @@ -108,6 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), TextPreset(String), + /// A built-in animation preset (Transitions…) by id. + Builtin(String), PresetFile(String), } @@ -340,18 +342,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // * Animation Presets: our text presets and the user's .ecpreset files. + // * Animation Presets: our own presets (Text, Transitions…) and the user's .ecpreset files. if view.presets { let text: Vec<(String, String)> = effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect(); + let builtin: Vec<(String, String, String)> = + effectcraft_engine::animation_presets().into_iter().filter(|(_, n, _)| query.is_empty() || n.to_lowercase().contains(&query)).collect(); + let mut categories: Vec<&str> = Vec::new(); + for (_, _, c) in &builtin { + if !categories.contains(&c.as_str()) { + categories.push(c); + } + } let files: Vec = user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect(); - if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { + let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty(); + if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); } } + for cat in &categories { + if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) { + continue; + } + for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + } + } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { for f in files { preset_row( @@ -418,6 +437,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), + Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), }; match app.session.execute(cmd, params) { diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 048b0b58..2e99c7d3 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -129,7 +129,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { // ---- Bottom buttons. ui.horizontal_wrapped(|ui| { - let r = ui.menu_button(tr("Add Formatting"), |ui| { + let r = crate::widgets::menu_button(ui, tr("Add Formatting"), |ui| { if ui.button(tr("Add Group")).clicked() { actions.push(("essential.addGroup".into(), json!({"comp": cid.0}))); ui.close(); @@ -143,7 +143,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) { // The selected property as a Font control (Source Text) or a uniform Scale slider. let has_sel = !app.session.state.selected_props.is_empty(); let r = ui.add_enabled_ui(has_sel, |ui| { - ui.menu_button(tr("Add Selected As"), |ui| { + crate::widgets::menu_button(ui, tr("Add Selected As"), |ui| { for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] { if ui.button(label).clicked() { actions.push(("essential.addProperty".into(), json!({"as": as_type}))); @@ -270,7 +270,7 @@ fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCon _ => vec![], }; let has_sel = !app.session.state.selected_props.is_empty(); - let r = ui.menu_button(RichText::new("⋯").size(10.0), |ui| { + let r = crate::widgets::menu_button(ui, RichText::new("⋯").size(10.0), |ui| { if ui.button(tr("Add Mirror")).clicked() { actions.push(("essential.addMirror".into(), json!({"comp": cid.0, "control": c.id, "group": group}))); ui.close(); diff --git a/crates/ui-egui/src/panels/expr_bar.rs b/crates/ui-egui/src/panels/expr_bar.rs index fc985d15..4f6d1c74 100644 --- a/crates/ui-egui/src/panels/expr_bar.rs +++ b/crates/ui-egui/src/panels/expr_bar.rs @@ -4,10 +4,11 @@ //! Automation ids: `viewer.exprErrors`, `viewer.exprErrors.prev`, `viewer.exprErrors.next`. use crate::i18n::tr; -use effectcraft_engine::commands::expr_tools::{self, ExprError}; +use effectcraft_engine::commands::expr_tools::ExprError; use effectcraft_engine::project::ItemId; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; +use std::sync::Arc; use crate::EffectcraftApp; use crate::theme::Tokens; @@ -17,35 +18,32 @@ pub const HEIGHT: f32 = 22.0; #[derive(Clone, Default)] struct Cache { - key: (u64, u64, i64), - errors: Vec, + errors: Arc<[ExprError]>, /// Shown error (index). current: usize, } -fn cache_id() -> egui::Id { - egui::Id::new("expr-error-bar") +fn cache_id(cid: ItemId) -> egui::Id { + egui::Id::new(("expr-error-bar", cid.0)) } -/// The comp's failing expressions (recomputed when the project or the time changes; kept while -/// playing). -fn errors(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache { - let t = app.session.time_of(cid); - let key = (app.session.revision, cid.0, t.0); - let mut c: Cache = ctx.data(|d| d.get_temp(cache_id())).unwrap_or_default(); - let stale = c.key != key && !(app.playback.playing && c.key.1 == cid.0); - if stale { - c.errors = expr_tools::errors(&app.session, cid, t); - c.key = key; - c.current = c.current.min(c.errors.len().saturating_sub(1)); - ctx.data_mut(|d| d.insert_temp(cache_id(), c.clone())); +/// Both layout and painting read the same completed diagnostics; neither evaluates expressions. +fn errors(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> Cache { + let mut c: Cache = ctx.data(|d| d.get_temp(cache_id(cid))).unwrap_or_default(); + let interacting = app.playback.playing || ctx.input(|i| i.pointer.any_down()) || app.ui.viewer.interacting || app.ui.viewer.property_interacting; + c.errors = app.expression_diagnostics.errors(&app.session, ctx, cid, interacting); + if let Some(error) = app.expression_diagnostics.take_failure() { + log::warn!("{error}"); + app.ui.status = error; } + c.current = c.current.min(c.errors.len().saturating_sub(1)); + ctx.data_mut(|d| d.insert_temp(cache_id(cid), c.clone())); c } /// Height the bar needs for `cid` (0 when every expression evaluates). -pub fn height(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 { - if app.session.expr.is_none() || errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT } +pub fn height(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32 { + if errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT } } pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) { @@ -71,12 +69,12 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId app.auto.add(auto, r, label); if resp.clicked() { c.current = (i + d) % n; - ctx.data_mut(|dd| dd.insert_temp(cache_id(), c.clone())); + ctx.data_mut(|dd| dd.insert_temp(cache_id(cid), c.clone())); } } p.text(count.center(), Align2::CENTER_CENTER, format!("{}/{n}", i + 1), Tokens::ui(11.0), t.text); let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default(); - let e = c.errors[i].clone(); + let Some(e) = c.errors.get(i).cloned() else { return }; let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y)); let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline")); p.with_clip_rect(msg_rect).text( @@ -87,6 +85,8 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId if resp.hovered() { Color32::WHITE } else { t.text }, ); app.auto.add("viewer.exprErrors", msg_rect, &e.describe(&comp_name)); + // Keep the former banner automation id as an alias for the unified error strip. + app.auto.add("viewer.expressionErrorBanner", msg_rect, &e.describe(&comp_name)); if resp.clicked() { let _ = app.session.execute("layer.select", json!({"comp": cid.0, "layers": [e.layer]})); let _ = app.session.execute("prop.select", json!({"comp": cid.0, "layer": e.layer, "prop": e.prop, "selectKeys": false})); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index ffb61738..f800b1a4 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -1,7 +1,6 @@ //! The expression editor of the Timeline, following Settings ▸ Scripting & Expressions: //! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching -//! and Word Wrap; and the Composition panel's expression error banner (Show Expression Error -//! Banner). +//! and Word Wrap. The Composition viewer uses the shared diagnostics in `expr_bar`. use effectcraft_engine::prefs::Scripting; use egui::text::{LayoutJob, TextFormat}; @@ -381,48 +380,6 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: out.response.response } -/// Settings ▸ Scripting & Expressions ▸ Show Expression Error Banner: the first failing -/// expression of the comp at the current time, in a banner along the bottom of the viewer -/// (click it to reveal the property). -pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) { - if !app.session.prefs.scripting.error_banner { - return; - } - let Some(cid) = app.session.active_comp_id() else { return }; - let key = (app.session.revision, cid.0, app.session.time().0); - let id = egui::Id::new("expr-error-banner"); - let cached: Option<((u64, u64, i64), Vec)> = ui.ctx().data(|d| d.get_temp(id)); - let errors = match cached { - Some((k, e)) if k == key => e, - _ => { - let e = app.session.expression_errors(cid); - ui.ctx().data_mut(|d| d.insert_temp(id, (key, e.clone()))); - e - } - }; - let Some(first) = errors.first() else { return }; - let layer = first["layer"].as_u64().unwrap_or(0); - let lname = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).map(|l| l.name.clone()).unwrap_or_default(); - let msg = format!( - "⚠ Expression error{}: {} ▸ {}: {}", - if errors.len() > 1 { format!(" (1 of {})", errors.len()) } else { String::new() }, - lname, - first["name"].as_str().unwrap_or(""), - first["error"].as_str().unwrap_or("").lines().next().unwrap_or("") - ); - let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); - let p = ui.painter(); - p.rect_filled(r, 0.0, Color32::from_rgb(0xf0, 0xc0, 0x30)); - p.text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, msg, Tokens::ui(11.5), Color32::BLACK); - app.auto.add("viewer.expressionErrorBanner", r, "Expression error banner"); - if ui.interact(r, id.with("click"), egui::Sense::click()).clicked() { - let p = serde_json::json!({"props": [{"layer": layer, "prop": first["prop"]}]}); - if let Err(e) = crate::menus::invoke(app, ui.ctx(), "timeline.revealProps", p) { - app.ui.status = e; - } - } -} - #[cfg(test)] mod contrast_tests { use super::*; diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index a8651464..a9e7cefb 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -19,7 +19,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.pasteboard); let Some(view) = app.session.state.footage_panel.clone() else { - p.text(rect.center(), Align2::CENTER_CENTER, tr("Double-click footage in the Project panel to open it here"), Tokens::ui(12.0), t.text_faint); + // Wrapped to the panel, so a narrow panel breaks the hint into lines instead of clipping it. + let hint = tr("Double-click footage in the Project panel to open it here"); + let mut job = egui::text::LayoutJob::simple(hint.to_owned(), Tokens::ui(12.0), t.text_faint, (rect.width() - 20.0).max(40.0)); + job.halign = egui::Align::Center; + let galley = p.layout_job(job); + p.galley(pos2(rect.center().x, rect.center().y - galley.size().y * 0.5), galley, t.text_faint); app.auto.add("footage.empty", rect, "Footage panel (empty)"); return; }; diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 039ebb69..2193b605 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -215,16 +215,15 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); let base = p.clone(); let (title, fields): (String, Vec) = match id { - // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). - "file.import" if !has(p, &["importAs"]) && is_layered(p) => ( - "Import Photoshop File".into(), - vec![Field::choice( - "importAs", - "Import Kind", - &[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))], - 1, - )], - ), + // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped + // files start at Settings ▸ Import ▸ Default Drag Import As. + "file.import" if !has(p, &["importAs"]) && is_layered(p) => { + let kinds = + [("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))]; + let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false); + let sel = if drag { kinds.iter().position(|(_, v)| v.as_str() == Some(s.prefs.drag_import_as())).unwrap_or(0) } else { 1 }; + ("Import Photoshop File".into(), vec![Field::choice("importAs", "Import Kind", &kinds, sel)]) + } // PDF / Illustrator files: Import Kind and the page. "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( "Import PDF / Illustrator File".into(), @@ -766,17 +765,32 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let h = 120.0 + rows; let mut regs: Vec<(String, egui::Rect, String)> = vec![]; let mut browse: Option = None; - // Wide enough for a label column and a 240 px popup without a horizontal scroll bar. - super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| { + let mut focused = false; + // Project Settings needs room for its labels and the selected GPU adapter name. At the + // generic form width its grid overflows, pushing the buttons off the visible area (#978). + let width = if f.title == "Project Settings" { 800.0 } else { 480.0 }; + super::dialogs::modal(ctx, &f.title.clone(), vec2(width, h), t, |ui| { egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { for (i, fl) in f.fields.iter_mut().enumerate() { ui.label(tr(&fl.label)); let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))); + // The first field takes the keyboard when the form opens. (A number edits + // as text once focused; the selection is clamped to what it shows.) + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, &" ".repeat(64)); + } + r.rect + } + FieldKind::Text(s) => { + let r = ui.add(egui::TextEdit::singleline(s).desired_width(220.0)); + if !std::mem::replace(&mut focused, true) { + crate::widgets::focus_on_open(ui.ctx(), &r, s); + } + r.rect } - FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect; @@ -892,7 +906,8 @@ pub(crate) fn close_info(app: &mut EffectcraftApp) { pub fn show_info(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (title, body) = app.dialog_state.info.clone(); let mut close = false; - super::dialogs::modal(ctx, &title, vec2(440.0, 160.0), t, |ui| { + // The text wraps within this small dialog; avoid a horizontal scrollbar changing its height. + super::dialogs::modal_vertical(ctx, &title, vec2(440.0, 160.0), t, |ui| { ui.label(tr(&body)); ui.add_space(16.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -948,6 +963,37 @@ mod tests { assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"})); } + /// A numeric form (Layer ▸ Transform ▸ Position…) opens with its first number being edited + /// and selected: typing replaces it, and one Enter confirms. + #[test] + fn a_numeric_form_opens_with_its_first_number_selected() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + app.dialog_state.form = Form { + title: "Position".into(), + command: "layer.setTransform".into(), + base: json!({"prop": "position"}), + fields: vec![Field::num("value[0]", "X", 960.0), Field::num("value[1]", "Y", 540.0)], + }; + app.dialog = Some(Dialog::Form); + let frame = |app: &mut EffectcraftApp, events: Vec| { + let raw = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + crate::panels::dialogs::show(app, ui.ctx()); + }) + .textures_delta + .clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("42".into())]); + assert_eq!(app.dialog_state.form.params()["value"], json!([42.0, 540.0])); + frame(&mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert!(app.dialog.is_none(), "one Enter confirms"); + } + /// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro /// XML; its fields register automation ids and OK runs `file.exportTimeline`. #[test] diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index a426f5a1..1ae74caa 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -11,7 +11,9 @@ //! - Reshape: correspondence point list buttons here, and on the Composition viewer the source //! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]); //! - EXtractoR: a Layer popup of the footage's OpenEXR layers, and Red / Green / Blue / Alpha -//! popups of its channels ([`channel_popup`]). +//! popups of its channels ([`channel_popup`]); +//! - Mesh Warp: on the Composition viewer, the distortion mesh's grid over the layer with a +//! draggable handle on every vertex ([`mesh_warp_overlay`], #303). //! //! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every //! handle registers an automation id. @@ -37,6 +39,7 @@ pub const COLORAMA: &str = "ec.color.colorama"; pub const GLOW: &str = "ec.stylize.glow"; pub const RESHAPE: &str = "ec.distort.reshape"; pub const EXTRACTOR: &str = "ec.3d.extractor"; +pub const MESH_WARP: &str = "ec.distort.meshwarp"; fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String { match g.get(id).map(|p| ectx.value(layer, p)) { @@ -1018,10 +1021,109 @@ pub fn reshape_overlay( } } +/// Mesh Warp's grid: (rows, columns, vertex offsets in layer pixels, row-major). A stored mesh +/// that doesn't match the grid (Rows or Columns changed since) reads as undistorted, as it +/// renders. +fn mesh_grid(layer: &Layer, ectx: &EvalCtx, g: &PropGroup) -> (usize, usize, Vec<(f64, f64)>) { + let n = |id: &str| { + let v = g.get(id).map(|p| ectx.value(layer, p).as_f64()).unwrap_or(7.0); + if v.is_finite() { v.round().clamp(1.0, 31.0) as usize } else { 7 } + }; + let (rows, cols) = (n("rows"), n("columns")); + let count = (rows + 1) * (cols + 1); + let mut offs = effectcraft_engine::effects::parse_mesh(&str_value(layer, ectx, g, "mesh")); + if offs.len() != count { + offs = vec![(0.0, 0.0); count]; + } + (rows, cols, offs) +} + +/// The `mesh` parameter's text for these offsets (the inverse of `parse_mesh`). +fn format_mesh(offs: &[(f64, f64)]) -> String { + let r = |v: f64| if v.is_finite() { (v * 1000.0).round() / 1000.0 + 0.0 } else { 0.0 }; + offs.iter().map(|o| format!("{},{}", r(o.0), r(o.1))).collect::>().join(" ") +} + +/// Mesh Warp on the Composition viewer (#303): the distortion mesh's grid over the layer, with +/// a handle on every vertex. Dragging a vertex moves it (its offset in the hidden `mesh` +/// parameter; the first drag of an undistorted mesh writes the whole grid). `fm` maps effect +/// space to comp. +#[allow(clippy::too_many_arguments)] +pub fn mesh_warp_overlay( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + layer: &Layer, + g: &PropGroup, + fm: &Mat3, + actions: &mut Actions, +) { + let (rows, cols, offs) = mesh_grid(layer, ectx, g); + let [lw, lh] = ectx.effect_bounds(layer).0; + let rest = |k: usize| [(k % (cols + 1)) as f64 / cols as f64 * lw, (k / (cols + 1)) as f64 / rows as f64 * lh]; + let screen: Vec = offs + .iter() + .enumerate() + .map(|(k, o)| { + let r = rest(k); + map.to_screen(fw::layer_to_comp(fm, [r[0] + o.0, r[1] + o.1])) + }) + .collect(); + let line = Stroke::new(1.0, Color32::from_white_alpha(150)); + for (k, s) in screen.iter().enumerate() { + if k % (cols + 1) < cols + && let Some(e) = screen.get(k + 1) + { + painter.line_segment([*s, *e], line); + } + if let Some(e) = screen.get(k + cols + 1) { + painter.line_segment([*s, *e], line); + } + } + let ctx = ui.ctx().clone(); + let accent = app.tokens.accent; + for (k, s) in screen.iter().enumerate() { + let hr = Rect::from_center_size(*s, vec2(10.0, 10.0)); + let id = egui::Id::new(("mesh-warp-vertex", g.uid, k)); + let resp = ui.interact(hr, id, Sense::drag()); + let hot = resp.hovered() || resp.dragged(); + let dot = Rect::from_center_size(*s, vec2(5.0, 5.0)); + painter.rect_filled(dot, 0.0, if hot { accent } else { Color32::WHITE }); + painter.rect_stroke(dot, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside); + let (row, col) = (k / (cols + 1), k % (cols + 1)); + app.auto.add(&format!("viewer.meshWarp.{}.{row}.{col}", g.uid), hr, &format!("Mesh vertex (row {row}, column {col})")); + if hot { + ctx.set_cursor_icon(egui::CursorIcon::Move); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(fm, map.to_comp(pos)) + { + let mut v = offs.clone(); + let r = rest(k); + if let Some(o) = v.get_mut(k) { + *o = (lp[0] - r[0], lp[1] - r[1]); + } + let key = gesture_key(ui, id, resp.drag_started()); + set_str(actions, layer, g, "mesh", format_mesh(&v), Some(key)); + } + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn mesh_text_round_trips() { + let offs = vec![(0.0, 0.0), (12.5, -3.25), (1.0 / 3.0, f64::NAN)]; + let s = format_mesh(&offs); + assert_eq!(s, "0,0 12.5,-3.25 0.333,0"); + assert_eq!(effectcraft_engine::effects::parse_mesh(&s), vec![(0.0, 0.0), (12.5, -3.25), (0.333, 0.0)]); + } + #[test] fn offset_points_and_palettes_round_trip() { let p = OffsetPoints::parse("0.5,0.75 0.1,0.5"); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs new file mode 100644 index 00000000..bb692302 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -0,0 +1,519 @@ +//! The Gradient Editor (#612), opened by a gradient property's "Edit Gradient…" in the Timeline +//! (Gradient Fill / Gradient Stroke ▸ Colors, Gradient Overlay ▸ Gradient): `prop.editGradient`. +//! Opacity stops sit above the preview bar and colour stops below it. Click above or below the +//! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the +//! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a +//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it +//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it +//! was. +//! +//! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, +//! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), +//! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, +//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. + +use effectcraft_engine::keyframe::{Gradient, Value}; +use effectcraft_engine::project::{ItemId, LayerId}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value as Json, json}; + +use crate::i18n::tr; +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +/// Which row of stops: opacity (above the bar) or colour (below it). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Track { + Opacity, + Color, +} + +/// A stop in the draft. `id` stays with the stop when it moves past another one (the tracks are +/// kept in order along the bar, so its index changes). +#[derive(Clone, Debug, PartialEq)] +pub struct Stop { + pub id: u64, + pub pos: f64, + pub value: T, +} + +/// The property being edited, as it was when the editor opened. +#[derive(Clone, Debug, PartialEq)] +pub struct Target { + pub comp: u64, + pub layer: u64, + pub prop: u64, + /// Composition seconds, snapped to the nearest frame: the value is read and set there. + pub time: f64, +} + +/// The editor's draft gradient and selection. +#[derive(Clone, Debug, Default)] +pub struct Draft { + pub target: Option, + pub colors: Vec>, + pub opacities: Vec>, + pub selected: Option<(Track, u64)>, + /// The gradient as it opened, stops in order: OK with an equal draft changes nothing. + pub baseline: Gradient, + /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. + pub neighbors: Vec<(usize, usize)>, + next_id: u64, +} + +fn by_pos(v: &mut [Stop]) { + // Stable: stops at the same location keep their order. + v.sort_by(|a, b| a.pos.total_cmp(&b.pos)); +} + +fn clamp_pos(p: f64) -> f64 { + if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 } +} + +impl Draft { + /// A draft of `g` (stops put in order, locations clamped to 0..1). + pub fn new(g: &Gradient, target: Option, neighbors: Vec<(usize, usize)>) -> Draft { + let mut d = Draft { target, neighbors, ..Default::default() }; + for &(pos, value) in &g.colors { + let id = d.fresh(); + d.colors.push(Stop { id, pos: clamp_pos(pos), value }); + } + for &(pos, value) in &g.opacities { + let id = d.fresh(); + d.opacities.push(Stop { id, pos: clamp_pos(pos), value }); + } + by_pos(&mut d.colors); + by_pos(&mut d.opacities); + d.baseline = d.gradient(); + d + } + + fn fresh(&mut self) -> u64 { + self.next_id = self.next_id.saturating_add(1); + self.next_id + } + + /// The draft as a gradient (the tracks are always in order). + pub fn gradient(&self) -> Gradient { + Gradient { colors: self.colors.iter().map(|s| (s.pos, s.value)).collect(), opacities: self.opacities.iter().map(|s| (s.pos, s.value)).collect() } + } + + pub fn len(&self, track: Track) -> usize { + match track { + Track::Color => self.colors.len(), + Track::Opacity => self.opacities.len(), + } + } + + /// The id of the stop `i`-th along the bar. + pub fn id_at(&self, track: Track, i: usize) -> Option { + match track { + Track::Color => self.colors.get(i).map(|s| s.id), + Track::Opacity => self.opacities.get(i).map(|s| s.id), + } + } + + /// Where stop `id` is along the bar (its index), if it exists. + pub fn index_of(&self, track: Track, id: u64) -> Option { + match track { + Track::Color => self.colors.iter().position(|s| s.id == id), + Track::Opacity => self.opacities.iter().position(|s| s.id == id), + } + } + + /// Add a stop at `pos` with that track's current value there, select it, return its id. + pub fn add(&mut self, track: Track, pos: f64) -> u64 { + let pos = clamp_pos(pos); + let g = self.gradient(); + let id = self.fresh(); + match track { + Track::Color => self.colors.push(Stop { id, pos, value: g.color_at(pos) }), + Track::Opacity => self.opacities.push(Stop { id, pos, value: g.opacity_at(pos) }), + } + self.sort(); + self.selected = Some((track, id)); + id + } + + /// Move stop `id` to `pos` (clamped) and keep the track in order. + pub fn move_to(&mut self, track: Track, id: u64, pos: f64) { + let pos = clamp_pos(pos); + match track { + Track::Color => { + if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) { + s.pos = pos; + } + } + Track::Opacity => { + if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) { + s.pos = pos; + } + } + } + self.sort(); + } + + /// Remove stop `id` unless it is the last of its track. Returns whether it went. + pub fn remove(&mut self, track: Track, id: u64) -> bool { + if self.len(track) <= 1 { + return false; + } + let before = self.len(track); + match track { + Track::Color => self.colors.retain(|s| s.id != id), + Track::Opacity => self.opacities.retain(|s| s.id != id), + } + if self.selected == Some((track, id)) { + self.selected = None; + } + self.len(track) < before + } + + /// Set colour stop `id`'s RGB, keeping its alpha. + pub fn set_rgb(&mut self, id: u64, rgb: [f32; 3]) { + if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) { + s.value = [rgb[0], rgb[1], rgb[2], s.value[3]]; + } + } + + /// Set opacity stop `id` (0..1). + pub fn set_opacity(&mut self, id: u64, a: f32) { + if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) { + s.value = if a.is_finite() { a.clamp(0.0, 1.0) } else { s.value }; + } + } + + fn sort(&mut self) { + by_pos(&mut self.colors); + by_pos(&mut self.opacities); + } + + /// The draft's stop counts differ from a neighbouring keyframe's, so the two can't + /// interpolate (gradient keyframes blend only with the same number of stops). + pub fn mismatched(&self) -> bool { + self.neighbors.iter().any(|&(c, o)| c != self.colors.len() || o != self.opacities.len()) + } +} + +/// `prop.editGradient {layer, prop, comp?}`: open the editor on that gradient property at the +/// current time (snapped to the nearest frame, as `prop.set` snaps it). +pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { + let cid = match p.get("comp").and_then(Json::as_u64) { + Some(c) => ItemId(c), + None => app.session.active_comp_id().ok_or("no composition is open")?, + }; + let comp = app.session.project.comp(cid).ok_or_else(|| format!("no composition {}", cid.0))?; + let lid = p.get("layer").and_then(Json::as_u64).ok_or("missing `layer`")?; + let layer = comp.layer(LayerId(lid)).ok_or_else(|| format!("no layer {lid}"))?; + let uid = p.get("prop").and_then(Json::as_u64).ok_or("missing `prop`")?; + let prop = layer.props.find(uid).ok_or_else(|| format!("no property @{uid}"))?; + let ct = comp.frame_rate.snap_nearest(app.session.time_of(cid)); + let lt = layer.layer_time(ct); + let Value::Gradient(g) = prop.value_at(lt) else { + return Err(format!("{} is not a gradient", prop.name)); + }; + // The keyframes either side of the edit time (one exactly there is the one being edited). + let counts = |v: &Value| match v { + Value::Gradient(g) => Some((g.colors.len(), g.opacities.len())), + _ => None, + }; + let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); + let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); + let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; + app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + app.dialog = Some(Dialog::GradientEditor); + Ok(json!({"dialog": "gradientEditor"})) +} + +/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition +/// time, or nothing when the draft is unchanged (so OK adds no keyframe). +fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { + app.dialog = None; + let d = std::mem::take(&mut app.dialog_state.gradient); + let g = d.gradient(); + let Some(tg) = d.target else { return }; + if g == d.baseline { + return; + } + let params = json!({"comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", "value": Value::Gradient(g).to_json()}); + if let Err(e) = crate::menus::invoke(app, ctx, "prop.set", params) { + app.ui.status = e; + } +} + +fn to_color32(c: [f32; 4]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) +} + +pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + if app.dialog_state.gradient.target.is_none() { + app.dialog = None; + return; + } + let mut answer: Option = None; + super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + let w = ui.available_width().max(200.0); + let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); + let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); + let opacity_row = Rect::from_min_max(pos2(bar.min.x, area.min.y), pos2(bar.max.x, bar.min.y - 2.0)); + let color_row = Rect::from_min_max(pos2(bar.min.x, bar.max.y + 2.0), pos2(bar.max.x, area.max.y)); + let pos_of = |x: f32| ((x - bar.min.x) / bar.width().max(1.0)) as f64; + let x_of = |p: f64| bar.min.x + bar.width() * p as f32; + let painter = ui.painter().clone(); + + // The bar: a checkerboard under the gradient, so opacity shows. + let cell = 6.0; + let mut y = bar.min.y; + let mut row = 0; + while y < bar.max.y { + let mut x = bar.min.x; + let mut col = row; + while x < bar.max.x { + let c = if col % 2 == 0 { Color32::from_gray(200) } else { Color32::from_gray(150) }; + painter.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + cell).min(bar.max.x), (y + cell).min(bar.max.y))), 0.0, c); + x += cell; + col += 1; + } + y += cell; + row += 1; + } + let g = app.dialog_state.gradient.gradient(); + let n = 96; + for k in 0..n { + let x0 = bar.min.x + bar.width() * k as f32 / n as f32; + let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32; + let c = g.sample((k as f64 + 0.5) / n as f64); + painter.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, to_color32(c)); + } + painter.rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + app.auto.add("dialog.gradient.bar", bar, "Gradient"); + + // Clicking a row's empty space adds a stop there. + for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] { + let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click()); + app.auto.add(id, rowr, id); + if r.clicked() + && let Some(p) = r.interact_pointer_pos() + { + app.dialog_state.gradient.add(track, pos_of(p.x)); + } + } + + // The stops (drawn and hit-tested over the rows, so they take the clicks). + for track in [Track::Opacity, Track::Color] { + for i in 0..app.dialog_state.gradient.len(track) { + let d = &app.dialog_state.gradient; + let Some(id) = d.id_at(track, i) else { continue }; + let (pos, fill) = match track { + Track::Color => d.colors.get(i).map(|s| (s.pos, to_color32([s.value[0], s.value[1], s.value[2], 1.0]))), + Track::Opacity => d.opacities.get(i).map(|s| (s.pos, Color32::from_gray((s.value.clamp(0.0, 1.0) * 255.0) as u8))), + } + .unwrap_or((0.0, Color32::WHITE)); + let selected = d.selected == Some((track, id)); + let x = x_of(pos); + let r = match track { + Track::Opacity => Rect::from_min_max(pos2(x - 5.0, opacity_row.max.y - 15.0), pos2(x + 5.0, opacity_row.max.y)), + Track::Color => Rect::from_min_max(pos2(x - 5.0, color_row.min.y), pos2(x + 5.0, color_row.min.y + 15.0)), + }; + painter.rect_filled(r, 1.0, fill); + painter.rect_stroke( + r, + 1.0, + Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }), + StrokeKind::Outside, + ); + let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag()); + let name = match track { + Track::Opacity => format!("dialog.gradient.opacityStop.{i}"), + Track::Color => format!("dialog.gradient.colorStop.{i}"), + }; + app.auto.add(&name, r, &name); + if resp.clicked() || resp.drag_started() { + app.dialog_state.gradient.selected = Some((track, id)); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + app.dialog_state.gradient.move_to(track, id, pos_of(p.x)); + } + } + } + ui.add_space(8.0); + + // The selected stop's fields. + let selected = app.dialog_state.gradient.selected; + let selected = selected.and_then(|(track, id)| app.dialog_state.gradient.index_of(track, id).map(|i| (track, id, i))); + ui.horizontal(|ui| { + let Some((track, id, i)) = selected else { + ui.label(egui::RichText::new(tr("Click above the bar to add an opacity stop, below it to add a color stop.")).color(t.text_dim)); + return; + }; + ui.label(tr("Location:")); + let d = &app.dialog_state.gradient; + let pos = match track { + Track::Color => d.colors.get(i).map(|s| s.pos), + Track::Opacity => d.opacities.get(i).map(|s| s.pos), + } + .unwrap_or(0.0); + let mut pct = pos * 100.0; + let r = ui.add(egui::DragValue::new(&mut pct).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%")); + app.auto.add("dialog.gradient.location", r.rect, "Location"); + if r.changed() { + app.dialog_state.gradient.move_to(track, id, pct / 100.0); + } + // Moving can reorder the track: find the stop again by its id, so the fields below + // show this stop and not the one now at its old index. + let Some(i) = app.dialog_state.gradient.index_of(track, id) else { return }; + match track { + Track::Opacity => { + ui.label(tr("Opacity:")); + let mut a = app.dialog_state.gradient.opacities.get(i).map_or(1.0, |s| s.value) as f64 * 100.0; + let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%")); + app.auto.add("dialog.gradient.opacity", r.rect, "Opacity"); + if r.changed() { + app.dialog_state.gradient.set_opacity(id, (a / 100.0) as f32); + } + } + Track::Color => { + ui.label(tr("Color:")); + let c = app.dialog_state.gradient.colors.get(i).map_or([1.0; 4], |s| s.value); + // The shared colour picker (sRGB, hex field, OK / Cancel); the stop keeps its alpha. + let mut rgb = [c[0], c[1], c[2]]; + let swatch = crate::widgets::srgb_color_button(ui, &mut rgb); + app.auto.add("dialog.gradient.color", swatch.rect, "Color"); + if swatch.changed() { + app.dialog_state.gradient.set_rgb(id, rgb); + } + } + } + let can_delete = app.dialog_state.gradient.len(track) > 1; + let r = ui.add_enabled(can_delete, egui::Button::new(tr("Delete"))); + app.auto.add("dialog.gradient.delete", r.rect, "Delete"); + if r.clicked() { + app.dialog_state.gradient.remove(track, id); + } + }); + if app.dialog_state.gradient.mismatched() { + ui.add_space(4.0); + ui.label(egui::RichText::new(tr("These adjacent keyframes cannot interpolate because their stop counts differ.")).color(t.danger).size(11.0)); + } + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.button(tr("Cancel")); + app.auto.add("dialog.gradient.cancel", r.rect, "Cancel"); + if r.clicked() { + answer = Some(false); + } + let r = ui.button(tr("OK")); + app.auto.add("dialog.gradient.ok", r.rect, "OK"); + if r.clicked() { + answer = Some(true); + } + }); + }); + // Delete / Backspace remove the selected stop, but not while typing in a field. + if answer.is_none() + && !ctx.egui_wants_keyboard_input() + && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && let Some((track, id)) = app.dialog_state.gradient.selected + { + app.dialog_state.gradient.remove(track, id); + } + match answer { + Some(true) => commit(app, ctx), + Some(false) => { + app.dialog = None; + app.dialog_state.gradient = Draft::default(); + } + None => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft() -> Draft { + Draft::new(&Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.5)] }, None, vec![]) + } + + /// Dragging the first stop past the second keeps the track in order and the same stop + /// selected; a stop added afterwards samples the reordered track. + #[test] + fn stops_stay_in_order_and_selected_while_moving() { + for track in [Track::Color, Track::Opacity] { + let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.6, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (0.6, 0.5)] }; + let mut d = Draft::new(&g, None, vec![]); + let first = d.id_at(track, 0).unwrap(); + d.selected = Some((track, first)); + d.move_to(track, first, 0.3); + d.move_to(track, first, 0.8); + assert_eq!(d.index_of(track, first), Some(1), "{track:?}: dragged past the stop at 0.6, it is now second"); + assert_eq!(d.selected, Some((track, first)), "{track:?}: the selection followed it"); + let g = d.gradient(); + let positions: Vec = match track { + Track::Color => g.colors.iter().map(|c| c.0).collect(), + Track::Opacity => g.opacities.iter().map(|c| c.0).collect(), + }; + assert!(positions.windows(2).all(|w| w[0] <= w[1]), "{track:?}: {positions:?}"); + let added = d.add(track, 0.5); + assert_eq!(d.index_of(track, added), Some(0), "{track:?}: the new stop sorts before the stops at 0.6 and 0.8"); + assert_eq!(d.selected, Some((track, added))); + } + // After the swap the colour track runs blue (at 0) to red (at 1): a stop added at 0.5 is + // their mix in the swapped order, not the original red-to-blue. + let mut d = draft(); + let red = d.id_at(Track::Color, 0).unwrap(); + let blue = d.id_at(Track::Color, 1).unwrap(); + d.move_to(Track::Color, red, 1.0); + d.move_to(Track::Color, blue, 0.0); + let id = d.add(Track::Color, 0.25); + let i = d.index_of(Track::Color, id).unwrap(); + let c = d.colors[i].value; + assert!(c[2] > c[0], "blue dominates near 0 after the swap: {c:?}"); + } + + #[test] + fn last_stop_stays_and_new_stops_keep_their_tracks_values() { + let mut d = draft(); + let a = d.id_at(Track::Opacity, 0).unwrap(); + let b = d.id_at(Track::Opacity, 1).unwrap(); + assert!(d.remove(Track::Opacity, a)); + assert!(!d.remove(Track::Opacity, b), "the last opacity stop stays"); + let mut d = draft(); + let id = d.add(Track::Opacity, 0.5); + let i = d.index_of(Track::Opacity, id).unwrap(); + assert!((d.opacities[i].value - 0.75).abs() < 1e-6, "the opacity track's own value"); + let id = d.add(Track::Color, 0.5); + let i = d.index_of(Track::Color, id).unwrap(); + assert!((d.colors[i].value[3] - 1.0).abs() < 1e-6, "colour stops keep their own alpha, not times opacity"); + d.set_rgb(id, [0.0, 1.0, 0.0]); + assert_eq!(d.colors[i].value, [0.0, 1.0, 0.0, 1.0]); + d.set_opacity(d.id_at(Track::Opacity, 0).unwrap(), f32::NAN); + assert!(d.opacities.iter().all(|s| s.value.is_finite())); + d.move_to(Track::Color, id, f64::INFINITY); + assert!(d.colors.iter().all(|s| (0.0..=1.0).contains(&s.pos))); + } + + #[test] + fn unchanged_drafts_equal_their_baseline() { + // Out-of-order and out-of-range stops come in sorted and clamped: still "unchanged". + let g = Gradient { colors: vec![(1.2, [0.0, 0.0, 1.0, 1.0]), (0.0, [1.0, 0.0, 0.0, 1.0])], opacities: vec![(0.5, 1.0)] }; + let d = Draft::new(&g, None, vec![]); + assert_eq!(d.gradient(), d.baseline); + assert_eq!(d.gradient().colors.iter().map(|c| c.0).collect::>(), vec![0.0, 1.0]); + let mut e = d.clone(); + e.add(Track::Opacity, 0.2); + assert_ne!(e.gradient(), e.baseline); + } + + #[test] + fn the_warning_follows_the_neighbours_stop_counts() { + let mut d = Draft::new(&Gradient::default(), None, vec![(2, 2), (2, 2)]); + assert!(!d.mismatched(), "same counts as both neighbours"); + d.add(Track::Color, 0.5); + assert!(d.mismatched(), "three colour stops next to keyframes with two"); + assert!(!Draft::new(&Gradient::default(), None, vec![]).mismatched(), "not animated: no neighbours"); + } +} diff --git a/crates/ui-egui/src/panels/gradient_handles.rs b/crates/ui-egui/src/panels/gradient_handles.rs new file mode 100644 index 00000000..bfe87a47 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient_handles.rs @@ -0,0 +1,104 @@ +//! A shape layer's Gradient Fill / Gradient Stroke start and end points in the Composition viewer +//! (#612). With the gradient, or a property in it, selected, its Start Point and End Point show as +//! draggable ⊕ controls with a guide between them, as in After Effects. A drag sets the point with +//! `prop.set`: one undo step per drag, and on an animated point a keyframe at the current time. +//! +//! The points are in the space of the shape group holding the gradient, so they follow that +//! group's transform (and the groups around it) as well as the layer's. +//! +//! Automation ids: `viewer.gradientPoint.` (the Start Point / End Point property's uid). + +use effectcraft_engine::geom::Mat3; +use effectcraft_engine::project::{Layer, LayerId, Node, PropGroup, Property}; +use effectcraft_engine::render::EvalCtx; +use egui::{Color32, Rect, Sense, Stroke, vec2}; +use serde_json::json; + +use super::fx_widgets as fw; +use crate::EffectcraftApp; + +/// Gradient Fill / Gradient Stroke groups in shape contents `g`, through nested groups. +fn gradient_paints<'a>(g: &'a PropGroup, depth: usize, out: &mut Vec<&'a PropGroup>) { + if depth >= 16 { + return; + } + for child in g.groups().filter(|c| c.enabled) { + match child.match_id.as_str() { + "gfill" | "gstroke" => out.push(child), + "group" | "contents" => gradient_paints(child, depth + 1, out), + _ => {} + } + } +} + +/// `g` or something inside it is selected on `layer`. +fn selected_in(g: &PropGroup, layer: LayerId, selected: &[(LayerId, u64)]) -> bool { + fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool { + g.uid == uid || (depth < 16 && g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(c) if contains(c, uid, depth + 1)))) + } + selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0)) +} + +/// Draw and drag the selected gradients' start and end points. `l2c` is the viewer's layer→comp +/// matrix (it knows the 3D view). Called after the viewer's own gestures, so a handle takes the +/// press before the layer would move. +pub fn viewer_hook( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &super::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, +) { + if !app.ui.viewer.show_layer_controls { + return; + } + let t = app.tokens; + let ctx = ui.ctx().clone(); + let selected_props = app.session.state.selected_props.clone(); + let selected_layers = app.session.state.selected_layers.clone(); + let mut actions: Vec<(String, serde_json::Value)> = vec![]; + for layer in ectx.comp.layers.iter().filter(|l| selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) { + let Some(contents) = layer.props.sub("contents") else { continue }; + let mut paints = vec![]; + gradient_paints(contents, 0, &mut paints); + if paints.is_empty() { + continue; + } + let m = l2c(ectx, layer); + for g in paints.into_iter().filter(|g| selected_in(g, layer.id, &selected_props)) { + // Gradient (group) space → comp: the enclosing groups' transforms, then the layer's. + let pm = m * effectcraft_engine::render::shapes::item_matrix(ectx, layer, g.uid).unwrap_or(Mat3::IDENTITY); + let pts: Vec<&Property> = ["start", "end"].iter().filter_map(|k| g.get(k)).collect(); + let screen: Vec = pts.iter().map(|pr| map.to_screen(fw::layer_to_comp(&pm, ectx.value(layer, pr).as_vec2()))).collect(); + if let [a, b] = screen[..] { + painter.line_segment([a, b], Stroke::new(1.0, Color32::from_white_alpha(110))); + } + for (pr, s) in pts.iter().zip(screen) { + let hr = Rect::from_center_size(s, vec2(16.0, 16.0)); + let id = egui::Id::new(("viewer-gradient-pt", pr.uid)); + let resp = ui.interact(hr, id, Sense::drag()); + let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE }; + fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160)); + fw::draw_crosshair(painter, s, 5.0, col); + app.auto.add(&format!("viewer.gradientPoint.{}", pr.uid), hr, &pr.name); + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name)); + } + if resp.dragged() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(lp) = fw::comp_to_layer(&pm, map.to_comp(pos)) + { + let key = super::effect_controls::gesture_key(ui, id, resp.drag_started()); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key}))); + } + } + } + } + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 2dbb9826..77e39c62 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -419,7 +419,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte && let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::(handle_drag_id())) { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); - let (dt, dv) = (tm.t(pt.x) - tm.t(press.x), vmap(pt.y) - vmap(press.y)); + // Shift keeps the handle on the time or the value axis, whichever moved more (as for keys). + let (time_ok, value_ok) = shift_axes(ui.input(|i| i.modifiers.shift), pt.x - press.x, pt.y - press.y); + let dt = if time_ok { tm.t(pt.x) - tm.t(press.x) } else { 0.0 }; + let dv = if value_ok { vmap(pt.y) - vmap(press.y) } else { 0.0 }; let merge = format!("ghandle-{uid}-{}", k.idx); for g in &grabs { actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge))); @@ -470,7 +473,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y); - let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }; + let (time_ok, value_ok) = shift_axes(mods.shift, dx, dy); // Graph Editor ▸ Snap: to the current time and other keys. let others: Vec = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect(); let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t }; @@ -703,3 +706,21 @@ type HandleDrag = (Pos2, Vec); fn handle_drag_id() -> egui::Id { egui::Id::new("graph-handle-drag") } + +/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along. +fn shift_axes(shift: bool, dx: f32, dy: f32) -> (bool, bool) { + if shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) } +} + +#[cfg(test)] +mod tests { + use super::shift_axes; + + #[test] + fn shift_locks_drag_to_dominant_axis() { + assert_eq!(shift_axes(false, 10.0, 3.0), (true, true)); + assert_eq!(shift_axes(true, 10.0, -3.0), (true, false)); + assert_eq!(shift_axes(true, 2.0, -9.0), (false, true)); + assert_eq!(shift_axes(true, 5.0, 5.0), (true, false)); + } +} diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index a5952d60..e5d8e4e0 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -44,6 +44,27 @@ fn tex_id() -> egui::Id { egui::Id::new("layer-panel-tex") } +fn fit_id() -> egui::Id { + egui::Id::new("layer-panel-fit") +} + +/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom +/// Out from Fit. +pub fn last_fit(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(fit_id())).unwrap_or(0.5) +} + +/// Zoom to `nz` (points per layer pixel) keeping layer point `q` under screen point `at`. +fn zoom_at(app: &mut EffectcraftApp, area: Rect, size: [f64; 2], at: Pos2, q: [f64; 2], nz: f32) { + let nz = nz.clamp(0.01, 32.0); + let origin = at - vec2(q[0] as f32 * nz, q[1] as f32 * nz); + let pan = origin + vec2(size[0] as f32 * nz / 2.0, size[1] as f32 * nz / 2.0) - area.center(); + if pan.x.is_finite() && pan.y.is_finite() { + app.ui.layer_zoom = Some(nz); + app.ui.layer_pan = [pan.x, pan.y]; + } +} + /// The layer shown: the opened one, else the first selected layer. pub fn current_layer(app: &EffectcraftApp) -> Option { let comp = app.session.active_comp()?; @@ -83,15 +104,46 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(top, 0.0, t.panel_bg); p.text(pos2(top.min.x + 10.0, top.center().y), Align2::LEFT_CENTER, tr_args("Layer: {}", &[&layer.name]), Tokens::semibold(12.0), t.text); app.auto.add("layerPanel.title", top, &layer.name); - // Bottom bar: View menu. + // Canvas geometry: Fit, or the panel's own magnification and pan. let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 30.0), rect.max); + let canvas_bottom = if roto.is_some() { bar.min.y - 18.0 } else { bar.min.y }; + let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom)); + // The layer's effect bounds, in effect space (where paint and Roto Brush strokes are). + let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0; + let fit = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01) as f32; + ctx.data_mut(|d| d.insert_temp(fit_id(), fit)); + let zoom = app.ui.layer_zoom.filter(|z| z.is_finite() && *z > 0.0).unwrap_or(fit) as f64; + let pan = match app.ui.layer_pan { + [x, y] if x.is_finite() && y.is_finite() && app.ui.layer_zoom.is_some() => vec2(x, y), + _ => vec2(0.0, 0.0), + }; + let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32); + + // Bottom bar: magnification and View menus. p.rect_filled(bar, 0.0, t.panel_bg); - p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim); + let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0)); + if let Some((z, up)) = super::viewer_tools::magnification_menu( + app, + ui, + mr, + "layer-mag", + "layerPanel.magnification", + "layerMagnification", + zoom as f32, + app.ui.layer_zoom, + false, + ) { + // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %. + app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z }; + app.ui.layer_pan = [0.0, 0.0]; + } + let vx = mr.max.x + 10.0; + p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim); let mut names = vec!["Masks".to_string()]; if let Some(fx) = layer.effects() { names.extend(fx.groups().map(|g| g.name.clone())); } - let vr = Rect::from_min_size(pos2(bar.min.x + 46.0, bar.center().y - 10.0), vec2(150.0, 20.0)); + let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0)); let pop = egui::Id::new("layer-view-pop"); if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() { widgets::open_popup(ui, pop); @@ -103,21 +155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc = super::timecode(&app.session, &comp, time); p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode); // Roto Brush: view / Freeze buttons and the segmentation span bar. - let mut canvas_bottom = bar.min.y; if roto.is_some() { super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer); - let sb = Rect::from_min_max(pos2(rect.min.x, bar.min.y - 18.0), pos2(rect.max.x, bar.min.y)); + let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y)); super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer); - canvas_bottom = sb.min.y; } // Canvas. - let area = Rect::from_min_max(pos2(rect.min.x, top.max.y), pos2(rect.max.x, canvas_bottom)); p.rect_filled(area, 0.0, t.pasteboard); - // The layer's effect bounds, in effect space (where paint and Roto Brush strokes are). - let [lw, lh] = effectcraft_engine::render::effect_bounds(&app.session.project, &comp, &layer).0; - let zoom = ((area.width() - 40.0) as f64 / lw).min((area.height() - 40.0) as f64 / lh).max(0.01); - let origin = area.center() - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32); let to_screen = |q: [f64; 2]| pos2(origin.x + (q[0] * zoom) as f32, origin.y + (q[1] * zoom) as f32); let to_layer = |s: Pos2| [((s.x - origin.x) as f64) / zoom, ((s.y - origin.y) as f64) / zoom]; let frame = Rect::from_min_max(to_screen([0.0, 0.0]), to_screen([lw, lh])); @@ -166,6 +211,53 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any + // tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out). + let hovered = ui.rect_contains_pointer(area); + if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) { + let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); + if scroll.is_finite() && scroll.abs() > 0.1 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp()); + } + } + // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. + if hovered && ui.input(|i| i.pointer.middle_down()) { + // Movement from before the press (in its frame) is not part of the drag. + let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() }); + if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + d.x, pan.y + d.y]; + } + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + return; + } + let space = super::space_hand(&ctx); + if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) { + let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag()); + let alt = ui.input(|i| i.modifiers.alt); + if resp.hovered() { + ctx.set_cursor_icon(match app.ui.tool { + Tool::Zoom if !space && alt => egui::CursorIcon::ZoomOut, + Tool::Zoom if !space => egui::CursorIcon::ZoomIn, + _ if resp.dragged() => egui::CursorIcon::Grabbing, + _ => egui::CursorIcon::Grab, + }); + } + if resp.dragged_by(egui::PointerButton::Primary) && (space || app.ui.tool == Tool::Hand) { + let d = resp.drag_delta(); + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + d.x, pan.y + d.y]; + } + if resp.clicked() + && !space + && app.ui.tool == Tool::Zoom + && let Some(pos) = resp.interact_pointer_pos() + { + zoom_at(app, area, [lw, lh], pos, to_layer(pos), zoom as f32 * if alt { 0.5 } else { 2.0 }); + } + app.auto.add("layerPanel.nav", area, "Layer panel navigation"); + return; + } // Interaction: Roto Brush / Refine Edge. if app.ui.tool.roto_kind(false).is_some() { super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer); diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index c3631604..edf46f4e 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -124,6 +124,8 @@ fn text_field(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut Str pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.marker.clone(); let (mut ok, mut cancel, mut delete) = (false, false, false); + // Cmd+Backspace (Ctrl elsewhere) is Delete, in a text field too. + let delete_key = super::dialogs::ui_command_key(ctx, egui::Key::Backspace); let title = d.title.clone(); super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -137,6 +139,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Comment:")); let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.comment); app.auto.add("dialog.marker.comment", r.rect, "Comment"); ui.end_row(); ui.label(tr("Chapter:")); @@ -217,7 +220,9 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); }); app.dialog_state.marker = d.clone(); - if ok { + delete |= delete_key && !ok && !cancel; + // Enter is OK, unless a text field has it. + if ok || (!cancel && !delete && super::dialogs::ui_enter(ctx)) { if let Err(e) = app.session.execute("markers.set", draft_params(&d)) { app.ui.status = e.to_string(); } @@ -337,7 +342,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: ctx.data_mut(|d| d.remove::<(Option, usize, bool)>(drag_state_id())); app.session.history.merge_key = None; } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { let run = |ui: &mut egui::Ui, label: &str, cmd: &str, params: Value, app: &mut EffectcraftApp| { if ui.button(label).clicked() { if cmd == "dialog" { @@ -496,5 +501,32 @@ mod tests { assert_eq!(m["label"], "Blue"); app.session.undo(); assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], ""); + // Enter is OK and Cmd+Backspace is Delete. + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + // The dialog opens with Comment focused (#922), a multi-line field where Enter types a + // new line; with the focus elsewhere, Enter is OK. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.auto.begin_frame(); + crate::panels::dialogs::show(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + ctx.memory_mut(|m| m.stop_text_input()); + app.dialog_state.marker.chapter = "Entered".into(); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Enter, egui::Modifiers::NONE); + assert!(app.dialog.is_none()); + assert_eq!(app.session.execute("markers.list", json!({})).unwrap()[0]["chapter"], "Entered"); + open_dialog(&mut app, MarkerRef { layer: None, index: 0 }).unwrap(); + // Also from a focused text field: Tab to one, then press the key. + for _ in 0..40 { + if ctx.egui_wants_keyboard_input() { + break; + } + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Tab, egui::Modifiers::NONE); + } + assert!(ctx.egui_wants_keyboard_input(), "a text field has focus"); + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::Backspace, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none()); + assert_eq!(app.session.execute("markers.list", json!({})).unwrap().as_array().map(Vec::len), Some(0), "deleted"); } } diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index cd6dca5d..4a0ab6ae 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -208,7 +208,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.drag_started() && !e.is_dir && e.kind.is_some() { egui::DragAndDrop::set_payload(&ctx, DragPayload::Files(vec![e.path.clone()])); } - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if !e.is_dir && ui.button(tr("Import")).clicked() { actions.push(("mediaBrowser.import", json!({"paths": [e.path]}))); ui.close(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index da2e05d0..1b6ef8c3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -17,6 +17,8 @@ pub mod footage_panel; pub mod forms; pub mod fx_editors; pub mod fx_widgets; +pub mod gradient_editor; +pub mod gradient_handles; pub mod graph; pub mod graph_tools; pub mod home; @@ -174,6 +176,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let id = egui::Id::new("panel-menu"); let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return }; + // The menu opens in the window that shows the panel (a panel can have its own window). + if app.panel_viewport(ui.ctx(), panel) != ui.ctx().viewport_id() { + return; + } let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -188,6 +194,14 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r }); close = true; } + // Multiple monitors: a native window of its own, which can go on another screen. + if !ui.ctx().embed_viewports() { + let windowed = app.in_panel_window(panel); + if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "window.panelWindow", serde_json::json!({"panel": panel, "window": !windowed})); + close = true; + } + } if !floating && ui.button(if app.ui.maximized == Some(panel) { tr("Restore Panel Size") } else { tr("Maximize Panel") }).clicked() { app.toggle_maximize(panel); close = true; diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index f4bf4272..8a40c56e 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -115,6 +115,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("New composition name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); app.auto.add("dialog.precompose.name", r.rect, "New composition name"); }); ui.add_space(10.0); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 21f84297..e18f76bc 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -3,8 +3,9 @@ //! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context //! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name), //! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop -//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are -//! undoable. +//! items on it for a comp from them, bit depth, delete), and the keyboard (the arrow keys select, +//! extend the selection and open / close folders; rows and buttons carry AccessKit names and +//! states for screen readers). Edits are engine commands, so they are undoable. use std::sync::{Arc, Mutex}; @@ -191,6 +192,130 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) { } } +/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift). +fn anchor_id() -> egui::Id { + egui::Id::new("project-selection-anchor") +} + +/// The item the arrow keys move from: the last item clicked or moved to. +fn cursor_id() -> egui::Id { + egui::Id::new("project-selection-cursor") +} + +/// An item row's widget: its click / drag area, keyboard focus and accessibility node. +fn row_id(item: ItemId) -> egui::Id { + egui::Id::new(("pitem", item.0)) +} + +/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view). +fn focused_row_id() -> egui::Id { + egui::Id::new("project-focused-row") +} + +/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys +/// apply. Any other focused widget (a text field, a button reached with Tab) keeps them. +fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool { + match ctx.memory(|m| m.focused()) { + None => app.ui.focused == crate::dock::PanelKind::Project, + Some(f) => ctx.data(|d| d.get_temp::(focused_row_id())).is_some_and(|row| row_id(row) == f), + } +} + +/// The Project panel's arrow keys, as in After Effects: Up / Down Arrow select the previous / +/// next item (the first or last when nothing is selected), Shift+Up / Down extend the selection +/// from its anchor, Right Arrow opens the selected folder and Left Arrow closes it (on an item +/// inside a folder, Left selects the folder). They apply while the panel has focus and nothing +/// else takes them: a dialog, an open menu, an inline rename. A row focused with Tab keeps them +/// too, and keyboard focus then follows the selection, so a screen reader reads the new item. +/// Returns the item the selection moved to. +fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option { + use egui::Key; + let keys: Vec<(Key, egui::Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key: k @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => { + Some((*k, *modifiers)) + } + _ => None, + }) + .collect() + }); + if keys.is_empty() + || app.dialog.is_some() + || widgets::any_menu_open(ctx) + || ctx.data(|d| d.get_temp::(edit_id())).is_some() + || !keys_reach_rows(app, ctx) + { + return None; + } + let row_focused = ctx.memory(|m| m.focused()).is_some(); + let mut moved = None; + for (key, m) in keys { + let (plain, shift) = (!m.any(), m.shift_only()); + // Folders opened or closed by an earlier key in this frame change the rows. + let rows = visible_rows(app); + let pos = |id: ItemId| rows.iter().position(|(r, _)| *r == id); + let sel = &app.session.state.project_selection; + let cursor = ctx.data(|d| d.get_temp::(cursor_id())).filter(|c| sel.contains(c)).or_else(|| sel.last().copied()); + let cur = cursor.and_then(pos); + let to = match key { + Key::ArrowUp | Key::ArrowDown if plain || shift => { + let Some(last) = rows.len().checked_sub(1) else { continue }; + let next = match (cur, key == Key::ArrowDown) { + (Some(c), true) => c.saturating_add(1).min(last), + (Some(c), false) => c.saturating_sub(1), + (None, true) => 0, + (None, false) => last, + }; + let Some((to, _)) = rows.get(next).copied() else { continue }; + if shift { + let anchor = ctx.data(|d| d.get_temp::(anchor_id())).and_then(pos).or(cur).unwrap_or(next); + app.session.state.project_selection = rows.get(anchor.min(next)..=anchor.max(next)).unwrap_or_default().iter().map(|(i, _)| *i).collect(); + to + } else { + app.session.state.project_selection = vec![to]; + ctx.data_mut(|d| d.insert_temp(anchor_id(), to)); + to + } + } + Key::ArrowRight | Key::ArrowLeft if plain => { + let Some((id, folder, parent)) = cursor.and_then(|c| app.session.project.item(c)).map(|it| (it.id, it.is_folder(), it.parent)) else { + continue; + }; + let open = app.ui.project_open_folders.contains(&id.0); + if key == Key::ArrowRight { + if folder { + app.ui.project_open_folders.insert(id.0); + } + continue; + } + if folder && open { + app.ui.project_open_folders.remove(&id.0); + continue; + } + let Some(parent) = parent.filter(|p| pos(*p).is_some()) else { continue }; + app.session.state.project_selection = vec![parent]; + ctx.data_mut(|d| d.insert_temp(anchor_id(), parent)); + parent + } + _ => continue, + }; + ctx.data_mut(|d| d.insert_temp(cursor_id(), to)); + moved = Some(to); + } + // The arrows are the list's, not egui's spatial focus moves. + if row_focused { + ctx.memory_mut(|m| { + m.move_focus(egui::FocusDirection::None); + if let Some(to) = moved { + m.request_focus(row_id(to)); + } + }); + } + moved +} + pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { let mut kids = app.session.project.children(folder); @@ -307,6 +432,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { )); } app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label }); + let column = crate::i18n::tr(if label.is_empty() { "Label" } else { label }); + let order = (app.ui.project_sort == *key).then(|| crate::i18n::tr(if app.ui.project_sort_desc { "descending" } else { "ascending" })); + resp.widget_info(|| { + let name = match order { + Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]), + None => crate::i18n::tr_args("Sort by {}", &[&column]), + }; + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); if resp.clicked() { if app.ui.project_sort == *key { app.ui.project_sort_desc = !app.ui.project_sort_desc; @@ -317,7 +451,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } column_menu(app, &resp); } - let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click()); + let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK); column_menu(app, &hresp); app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)"); // Rows. @@ -327,7 +461,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // The list's empty area (under the rows and the scroll bar, which take their own clicks): // a click deselects, a drag draws a selection box, a double-click imports, as in After // Effects (File ▸ Import ▸ File...). - let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag()); + // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key. + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG); app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); @@ -340,11 +475,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow); } + let moved = keyboard_nav(app, &ctx); let rows = visible_rows(app); // Enter renames the selected item (Project panel focused, not typing). if app.ui.focused == crate::dock::PanelKind::Project && app.dialog.is_none() - && !ctx.egui_wants_keyboard_input() + && keys_reach_rows(app, &ctx) && ctx.data(|d| d.get_temp::(edit_id())).is_none() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -382,6 +518,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end). + if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) { + let top = row as f32 * ROW_H; + // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins. + app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top); + } app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll); let scroll = app.ui.project_scroll; let first = ((scroll / ROW_H).floor() as usize).min(rows.len()); @@ -406,14 +548,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ); // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets // on top of it take their own clicks. - let mut resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); + let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}", id.0), r, &it.name); + // For screen readers: a tree item named by the item and its type, with its selection, + // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply. + let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name())))); + ctx.accesskit_node_builder(resp.id, |n| { + n.set_role(egui::accesskit::Role::TreeItem); + n.set_selected(selected); + n.set_level(depth.saturating_add(1)); + if let Some(open) = open { + n.set_expanded(open); + } + }); + if resp.has_focus() { + ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id)); + } + if resp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::Project; + } let _ = i; let x0 = r.min.x + 8.0 + 14.0 * *depth as f32; if it.is_folder() { let open = app.ui.project_open_folders.contains(&id.0); let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0)); - if widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t).clicked() { + let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t); + tresp.widget_info(|| { + let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); + if tresp.clicked() { if open { app.ui.project_open_folders.remove(&id.0); } else { @@ -437,19 +602,44 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if px.enabled { "in use" } else { "off" } )); app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" }); + presp.widget_info(|| { + let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); if presp.clicked() { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0, r.max.y)).intersect(list); + // Footage that can't be decoded (no decoder for its codec / profile): a warning marker + // at the end of the name cell, the reason on hover. + let problem = match &it.kind { + ItemKind::Footage(f) => app.session.footage.problem(f), + _ => None, + }; + let name_clip = + Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); + if let Some(why) = &problem { + let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0)); + lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE)); + lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK); + ui.interact(m.expand(2.0).intersect(list), egui::Id::new(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str()); + app.auto.add(&format!("project.item.{}.problem", id.0), m, why); + } let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0)); // Inline edits (rename / comment). let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool { let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false }; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell)); - let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0))); + // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no + // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel. + let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter)); app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field); - if resp.lost_focus() { + let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab)); + if tab { + resp.surrender_focus(); + } + if resp.lost_focus() || tab { ctx.data_mut(|d| d.remove::(edit_id())); if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) { let (cmd, params) = if ef == "name" { @@ -485,6 +675,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.rect_filled(sw, 2.0, t.label(it.label)); let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click()); app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name()); + sresp.widget_info(|| { + let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); let pop = egui::Id::new(("plabel-pop", id.0)); if sresp.clicked() || sresp.secondary_clicked() { widgets::open_popup(ui, pop); @@ -510,8 +704,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if cell_edit(app, ui, &mut actions, "comment", cr) { continue; } - let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click()); + let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK); app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment); + cresp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment])) + }); if cresp.double_clicked() { ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone()))); } else { @@ -529,12 +726,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() { lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag()); + // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring. + if ctx.memory(|m| m.has_focus(row_id(*id))) { + lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + // Part of the row: not a second Tab stop. + let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG); app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); let resp = resp.union(nresp); if resp.clicked() { let m = ui.input(|i| i.modifiers); - let anchor_key = egui::Id::new("project-selection-anchor"); + let anchor_key = anchor_id(); + ctx.data_mut(|d| d.insert_temp(cursor_id(), *id)); let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied()); if m.shift && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a)) @@ -578,7 +781,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.state.project_selection = vec![*id]; } let footage = matches!(it.kind, ItemKind::Footage(_)); - resp.context_menu(|ui| { + crate::widgets::context_menu(&resp, |ui| { if ui.button(crate::i18n::tr("Delete")).clicked() { actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); ui.close(); @@ -592,7 +795,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.close(); } if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() { - actions.push(("project.move".into(), json!({"items": [id.0], "folder": null}))); + actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null}))); ui.close(); } if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() { @@ -692,6 +895,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); app.auto.add(&format!("project.{id}"), r, tip); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); if resp.clicked() { let footage = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_))); @@ -724,6 +928,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click()); p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim }); app.auto.add("project.bitDepth", dr, depth); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth]))); // Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects). if dresp.clicked() { if ui.input(|i| i.modifiers.alt) { @@ -734,16 +939,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings")); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); - if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")).clicked() - && !app.session.state.project_selection.is_empty() - { + let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")); + trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items"))); + if trash.clicked() && !app.session.state.project_selection.is_empty() { actions.push(("project.delete".into(), json!({}))); } app.auto.add("project.delete", tr, "Delete"); if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } - empty.context_menu(|ui| { + crate::widgets::context_menu(&empty, |ui| { for (key, label, command) in [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")] { @@ -809,7 +1014,7 @@ fn label_index(l: Label) -> usize { /// The column header's context menu: show or hide the optional columns. fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { - resp.context_menu(|ui| { + crate::widgets::context_menu(resp, |ui| { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 502b8410..e07efe69 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -245,7 +245,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren } v.push((mark(o.audio_channels == 1, "Audio Channels: Mono"), json!({"audioChannels": "mono"}))); v.push((mark(o.audio_channels != 1, "Audio Channels: Stereo"), json!({"audioChannels": "stereo"}))); - if matches!(o.format, OutputFormat::ProRes | OutputFormat::Wav | OutputFormat::Aiff) { + if matches!(o.format, OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Wav | OutputFormat::Aiff) { for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] { v.push((mark(o.audio_format == f, &tr_args("Sample Format: {}", &[&(f.label())])), json!({"audioFormat": f.label()}))); } @@ -559,7 +559,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.focused = crate::dock::PanelKind::RenderQueue; } let item_id = it.id; - row_resp.context_menu(|ui| { + crate::widgets::context_menu(&row_resp, |ui| { if ui.button(tr("Duplicate")).clicked() { actions.push(("renderQueue.duplicate", json!({"item": item_id}))); ui.close(); diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index e8793388..f0a3975f 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -4,7 +4,9 @@ //! `scriptui.close` commands, which run the script's handlers. Every control registers an //! automation id `scriptui..` (and `scriptui..` when the script //! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and -//! sliders send live `changing` updates (onChanging) while typing / dragging. +//! sliders send live `changing` updates (onChanging) while typing / dragging. Mouse events +//! (`mousedown`, `mouseup`, `mousemove`, `mouseover`, `mouseout`, `click`) on a control go to the +//! script through `scriptui.mouse` when a listener wants them (`Widget::mouse`). use crate::i18n::tr; use effectcraft_engine::Services; @@ -22,6 +24,8 @@ enum Act { /// A live update (each keystroke / slider step): fires onChanging. Changing(u32, u32, Value), Close(u32), + /// A mouse event (`scriptui.mouse` parameters without window and widget). + Mouse(u32, u32, Value), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -335,6 +339,132 @@ fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) true } +/// Where the pointer was and what it did over one script window (for mouseover / mouseout and +/// click counts). +#[derive(Clone, Default)] +struct MouseTrack { + hover: Option, + at: Option, + /// The last press: control, time, position (a quick second press there counts as a double + /// click). + last_down: Option<(u32, f64, egui::Pos2)>, + clicks: u32, + /// The control and button pressed (a release on the same control is a click). + down: Option<(u32, u8)>, +} + +/// The deepest visible control of `w` at `p` (`w` itself when no child is there): later +/// siblings are on top; a tabbed panel shows its active tab only. +fn hit_test(w: &Widget, origin: egui::Pos2, p: egui::Pos2, depth: usize) -> Option<&Widget> { + if !w.visible || !rect_of(origin, w.bounds).contains(p) { + return None; + } + // Widget trees come from scripts: the depth is bounded. + if depth < 64 { + if w.kind == WidgetKind::TabbedPanel { + if let Some(hit) = w.children.get(w.active_tab).and_then(|t| hit_test(t, origin, p, depth + 1)) { + return Some(hit); + } + } else if let Some(hit) = w.children.iter().rev().find_map(|c| hit_test(c, origin, p, depth + 1)) { + return Some(hit); + } + } + Some(w) +} + +fn wants_mouse(w: &Widget, depth: usize) -> bool { + !w.mouse.is_empty() || (depth < 64 && w.children.iter().any(|c| wants_mouse(c, depth + 1))) +} + +/// Mouse events over a script window's controls (drawn at `origin`, visible in `area`) for the +/// script's listeners: mouseover / mouseout when the control under the pointer changes, +/// mousemove, mousedown / mouseup, and click (a press and release of the left button on the +/// same control; it replaces the plain click the control reported, so a checkbox toggles once). +/// Only events a listener wants are sent; none while `active` is false (a modal dialog is up). +fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: &Widget, active: bool, acts: &mut Vec) { + let id = egui::Id::new(("sui-mouse", win)); + if !wants_mouse(root, 0) { + ui.data_mut(|d| d.remove::(id)); + return; + } + let mut st: MouseTrack = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + let (latest, events, mods, time) = ui.input(|i| (i.pointer.latest_pos(), i.events.clone(), i.modifiers, i.time)); + let ctx = ui.ctx(); + let layer = ui.layer_id(); + let target = |p: egui::Pos2| -> Option<&Widget> { + if !active || !area.contains(p) || ctx.layer_id_at(p).is_some_and(|l| l != layer) { + return None; + } + hit_test(root, origin, p, 0) + }; + let send = |w: &Widget, ty: &str, p: egui::Pos2, button: u8, detail: u32, acts: &mut Vec| { + if !w.enabled || !w.mouse.iter().any(|m| m == ty) { + return; + } + let at = rect_of(origin, w.bounds).min; + acts.push(Act::Mouse( + win, + w.id, + json!({ + "type": ty, "x": p.x - at.x, "y": p.y - at.y, "screenX": p.x, "screenY": p.y, + "button": button, "detail": detail, + "altKey": mods.alt, "ctrlKey": mods.ctrl, "shiftKey": mods.shift, "metaKey": mods.mac_cmd, + }), + )); + }; + // The pointer moved: out of the old control, over the new one, along it. + let now = latest.and_then(|p| target(p).map(|w| (w, p))); + let now_id = now.map(|(w, _)| w.id); + if now_id != st.hover { + if let Some(old) = st.hover.and_then(|h| root.find(h)) { + send(old, "mouseout", latest.unwrap_or(origin), 0, 0, acts); + } + if let Some((w, p)) = now { + send(w, "mouseover", p, 0, 0, acts); + } + st.hover = now_id; + } + if let Some((w, p)) = now + && st.at != Some(p) + { + send(w, "mousemove", p, 0, 0, acts); + } + st.at = latest; + // Presses and releases. + for e in &events { + let egui::Event::PointerButton { pos, button, pressed, .. } = e else { continue }; + let b = match button { + egui::PointerButton::Primary => 0, + egui::PointerButton::Middle => 1, + egui::PointerButton::Secondary => 2, + _ => continue, + }; + let Some(w) = target(*pos) else { + if !pressed { + st.down = None; + } + continue; + }; + if *pressed { + let again = st.last_down.is_some_and(|(c, t, at)| c == w.id && time - t < 0.5 && at.distance(*pos) < 6.0); + st.clicks = if again { st.clicks.saturating_add(1) } else { 1 }; + st.last_down = Some((w.id, time, *pos)); + st.down = Some((w.id, b)); + send(w, "mousedown", *pos, b, st.clicks, acts); + } else { + send(w, "mouseup", *pos, b, st.clicks, acts); + if st.down == Some((w.id, b)) && b == 0 && w.enabled && w.mouse.iter().any(|m| m == "click") { + // The click with the pointer's details, after mousedown / mouseup, in place of + // the plain click the control reported. + acts.retain(|a| !matches!(a, Act::Click(aw, ac) if *aw == win && *ac == w.id)); + send(w, "click", *pos, b, st.clicks, acts); + } + st.down = None; + } + } + ui.data_mut(|d| d.insert_temp(id, st)); +} + /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. fn snug(r: Rect, text: &str) -> Rect { let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width()); @@ -348,6 +478,13 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Mouse(w, id, mut v) => { + if let Some(o) = v.as_object_mut() { + o.insert("window".into(), json!(w)); + o.insert("widget".into(), json!(id)); + } + ("scriptui.mouse", v) + } }; match crate::menus::invoke(app, ctx, cmd, p) { Ok(r) => { @@ -372,7 +509,8 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) { let t = app.tokens; let mut acts = vec![]; let screen = ctx.content_rect(); - if windows.iter().any(|w| w.modal) { + let blocked = windows.iter().any(|w| w.modal); + if blocked { // A modal dialog blocks the app behind it, as in After Effects. let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| { ui.allocate_exact_size(screen.size(), Sense::click_and_drag()); @@ -395,6 +533,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); draw_children(app, ui, rect.min, w.id, &w.root, &mut acts); + mouse_events(ui, rect.min, rect, w.id, &w.root, w.modal || !blocked, &mut acts); rect }); if let Some(r) = resp { @@ -418,11 +557,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { let (pw, ph) = (rect.width() as f64, rect.height() as f64); w.root.fixed_bounds = Some([0.0, 0.0, pw.max(w.root.bounds[2]), ph.max(w.root.bounds[3])]); layout(&mut w); + // A modal script dialog blocks the panels too. + let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); draw_children(app, ui, r.min, id, &w.root, &mut acts); + mouse_events(ui, r.min, r.intersect(ui.clip_rect()), id, &w.root, !modal, &mut acts); }); app.auto.add(&format!("scriptui.{id}"), rect, &w.title); let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 385febef..f7a45feb 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -10,7 +10,7 @@ use crate::i18n::{tr, tr_args}; use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages}; use effectcraft_engine::segment::Task; -use egui::{Color32, RichText, vec2}; +use egui::{Color32, Rect, RichText, vec2}; use serde_json::{Value, json}; use crate::theme::{ThemeKind, Tokens}; @@ -118,7 +118,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); - let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25))); + // A typed number applies once entered: typing 85 into UI Scale + // doesn't resize the interface to 75 % at its first digit. + let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); + let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -152,7 +155,6 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value r } Kind::Choice(opts) => { - // (A number with choices, UI Scale, shows as its text.) let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string); let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string(); egui::ComboBox::from_id_salt(("settings", key)) @@ -331,11 +333,39 @@ fn theme_card( theme_preview(ui, kind, ui.available_width()); ui.add_space(4.0); for (label, value) in options { - let r = ui.radio(cur == *value, tr(label)); - reg(app, &format!("settings.{key}.{value}"), &r, label); - if r.clicked() && cur != *value { - acts.push(Act::Set(key.into(), json!(value))); - } + let row = egui::Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio(cur == *value, tr(label)); + reg(app, &format!("settings.{key}.{value}"), &r, label); + if r.clicked() && cur != *value { + acts.push(Act::Set(key.into(), json!(value))); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut favorites = app.session.prefs.appearance.favorite_themes.clone(); + let favorite = favorites.iter().any(|id| id == value); + let tip = tr(if favorite { "Remove from favorites" } else { "Add to favorites" }); + let r = ui.add_visible(favorite || hovered, egui::Button::new("").min_size(vec2(24.0, 24.0)).frame(false)).on_hover_text(tip); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{}: {}", tip, tr(label)))); + reg(app, &format!("settings.appearance.favoriteThemes.{value}"), &r, tip); + if favorite || hovered { + crate::icons::paint( + ui.painter(), + r.rect.shrink(4.0), + if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, + if favorite { t.accent } else { t.text_dim }, + ); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != value); + } else { + favorites.push((*value).into()); + } + acts.push(Act::Set("appearance.favoriteThemes".into(), json!(favorites))); + } + }); + }); } }); } @@ -602,10 +632,40 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default(); let mut acts: Vec = vec![]; let (mut ok, mut cancel) = (false, false); - super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| { - let body_h = (ctx.content_rect().height() - 170.0).clamp(120.0, 470.0); - ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| { - ui.set_height(body_h); + super::dialogs::modal_fit(ctx, "Settings", vec2(820.0, 600.0), t, |ui, height| { + // The buttons go first, from the bottom up, and the pages get the height they leave: at + // a high UI Scale the pages scroll and the buttons stay in view. + let area = ui.allocate_space(vec2(ui.available_width(), height)).1; + let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min))); + foot.horizontal(|ui| { + let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous"))); + app.auto.add("settings.previous", r.rect, "Previous"); + if r.clicked() { + page = all[idx - 1].id.to_string(); + } + let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next"))); + app.auto.add("settings.next", r.rect, "Next"); + if r.clicked() { + page = all[idx + 1].id.to_string(); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + app.auto.add("settings.ok", r.rect, "OK"); + ok |= r.clicked(); + let r = ui.button(tr("Cancel")); + app.auto.add("settings.cancel", r.rect, "Cancel"); + cancel |= r.clicked(); + }); + }); + foot.separator(); + foot.add_space(8.0); + let body_h = (foot.min_rect().top() - area.top()).max(0.0); + // (A child, clipped to its share, so a wide page can't widen the dialog under the buttons.) + let pages = Rect::from_min_size(area.min, vec2(area.width(), body_h)); + let mut body = ui.new_child(egui::UiBuilder::new().max_rect(pages).layout(egui::Layout::left_to_right(egui::Align::Min))); + body.set_clip_rect(pages.intersect(body.clip_rect())); + { + let ui = &mut body; // Page list (left, like After Effects). ui.vertical(|ui| { ui.set_width(180.0); @@ -640,33 +700,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let p = &all[idx]; ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); ui.add_space(6.0); - egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| { page_ui(app, ui, p, &cur, t, &mut acts); }); }); - }); - ui.add_space(8.0); - ui.separator(); - ui.horizontal(|ui| { - let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous"))); - app.auto.add("settings.previous", r.rect, "Previous"); - if r.clicked() { - page = all[idx - 1].id.to_string(); - } - let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next"))); - app.auto.add("settings.next", r.rect, "Next"); - if r.clicked() { - page = all[idx + 1].id.to_string(); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); - app.auto.add("settings.ok", r.rect, "OK"); - ok |= r.clicked(); - let r = ui.button(tr("Cancel")); - app.auto.add("settings.cancel", r.rect, "Cancel"); - cancel |= r.clicked(); - }); - }); + } }); app.dialog_state.settings_page = page; for a in acts { @@ -778,6 +816,9 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); }); + if choice.is_none() && super::dialogs::ui_enter(ctx) { + choice = Some("autosave"); + } let Some(c) = choice else { return }; app.dialog = None; app.recovery = None; diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 021dffe7..c8948ab8 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -420,6 +420,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // ---- search + command list + editor ui.horizontal(|ui| { let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tr("Search commands or shortcuts")).desired_width(360.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &st.query); app.auto.add("shortcuts.search", r.rect, "Search"); }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index a32d74b4..ae2294f1 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -8,6 +8,7 @@ use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; +use crate::state::TextColorPick; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -201,7 +202,21 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } - p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0)); + let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key); + let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t); + app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper")); + if eye.clicked() { + if active { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else if let Some(tt) = &target { + app.ui.fx_pick = None; + app.ui.text_color_pick = Some(TextColorPick { layer: tt.layer, field: key.into(), range: tt.range }); + app.ui.status = crate::i18n::tr_args("Click in the Composition panel to sample text {}", &[&label]); + } + } + p.text(pos2(er.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); } y += 32.0; // Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript. @@ -579,6 +594,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: }); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; + // The Variable Fonts filter (After Effects 26.3) stays as set while the app runs, like the + // menu's other settings; the search query resets on every open. + let variable_id = id.with("variable-only"); + let mut only_variable = ui.data(|d| d.get_temp::(variable_id)).unwrap_or(false); // The font field itself becomes editable. The popup contains only results. let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max); let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y)); @@ -608,12 +627,28 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(rect.width().max(180.0)); + // Filter row: a checkbox that keeps only the variable fonts. + let filter_h = 22.0; + let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, "")); + let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h)); + let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max); + let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); + ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); + app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); + let toggled = row.clicked() || boxed.clicked(); + if toggled { + only_variable = !only_variable; + ui.data_mut(|d| d.insert_temp(variable_id, only_variable)); + // Typing goes on into the search field. + ui.memory_mut(|m| m.request_focus(search_id)); + } let query = state.query.trim().to_lowercase(); let mut seen = std::collections::HashSet::new(); let filtered: Vec<_> = rows .iter() .filter(|r| { !r.family.is_empty() + && (!only_variable || r.variable) && (query.is_empty() || ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family))) }) @@ -621,7 +656,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: if fresh { state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0); } - if search.changed() { + if search.changed() || toggled { state.selected = 0; } let last = filtered.len().saturating_sub(1); @@ -637,10 +672,12 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } // Give the result list its actual height even when the previous query had // only one row. A cached tiny Area must not collapse a reopened long list. - let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height); - ui.set_height(height); + let filter_room = filter_h + ui.spacing().item_spacing.y; + let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0)); + // A minimum, from below the filter row (a maximum would move the cursor back up). + ui.set_min_height(height); let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height); - if search.changed() { + if search.changed() || toggled { scroll = scroll.vertical_scroll_offset(0.0); } scroll.show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d..72f10429 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -488,6 +488,21 @@ impl TMap { } } +/// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when +/// the project's Time Display Style is Frames, the frame number the current-time display shows +/// (`00015`, counted from Start Numbering Frames At) (#710). +fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::ProjectSettings) -> String { + if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Frames { + return format!("{:05}", fno.saturating_add(settings.frame_start)); + } + let fps_i = fps_i.max(1); + if fno >= 60 * fps_i * 60 { + format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i) + } else { + format!("{:02}:{:02}f", fno / fps_i, fno % fps_i) + } +} + fn tl_map_id() -> egui::Id { egui::Id::new("timeline-map") } @@ -563,6 +578,18 @@ pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// What double-clicking a layer's name or bar does, as in After Effects: a precomp opens, footage +/// and solids open in the Layer panel (paint happens there), text is edited in the viewer with the +/// whole text selected. `None`: the name renames, the bar does nothing. +fn double_click_action(layer: &Layer) -> Option<(String, serde_json::Value)> { + match &layer.source { + LayerSource::Comp { item } => Some(("comp.open".into(), json!({"comp": item.0}))), + LayerSource::Footage { .. } | LayerSource::Solid { .. } => Some(("layer.openLayer".into(), json!({"layer": layer.id.0}))), + LayerSource::Text => Some(("text.edit".into(), json!({"layer": layer.id.0}))), + _ => None, + } +} + /// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only. fn start_rename(ctx: &egui::Context, id: u64, name: &str) { ctx.data_mut(|d| { @@ -1154,9 +1181,16 @@ fn layer_switch( id: egui::Id, layer: u64, name: &'static str, + others: bool, actions: &mut Vec<(String, serde_json::Value)>, ) { let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); + // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), + // and come back on the next Alt-click. + if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) { + actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true}))); + return; + } if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) { actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { @@ -1404,7 +1438,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag()); app.auto.add("timeline.workArea.bar", bar, "Work area"); // Right-click: the work area's commands (After Effects). - bresp.context_menu(|ui| { + crate::widgets::context_menu(&bresp, |ui| { let mut chosen = None; for id in ["comp.trimToWorkArea", "edit.liftWorkArea", "edit.extractWorkArea"] { crate::menus::entry_for(app, ui, id, &mut chosen); @@ -1513,11 +1547,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let x = tm.x(s); pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); let fno = (s / fd).round() as i64; - let label = if fno >= 60 * fps_i * 60 { - format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i) - } else { - format!("{:02}:{:02}f", fno / fps_i, fno % fps_i) - }; + let label = ruler_label(fno, fps_i, &app.session.project.settings); pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); for k in 1..5 { let xx = tm.x(s + secs_per_label * k as f64 / 5.0); @@ -1651,7 +1681,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x)); ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-colhdr-under"), under.unwrap_or(""))); } - hresp.context_menu(|ui| { + crate::widgets::context_menu(&hresp, |ui| { let under: &str = ctx.data(|d| d.get_temp(egui::Id::new("tl-colhdr-under"))).unwrap_or(""); if !under.is_empty() && under != "name" && ui.button(crate::i18n::tr("Hide This")).clicked() { actions.push(("timeline.column".into(), json!({"column": under, "visible": false}))); @@ -1776,7 +1806,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } // Label swatch. @@ -1870,20 +1900,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if row_resp.double_clicked() { - match &layer.source { - LayerSource::Comp { item } => actions.push(("comp.open".into(), json!({"comp": item.0}))), - // Footage and solids open in the Layer panel (paint happens there). - LayerSource::Footage { .. } | LayerSource::Solid { .. } => { - actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0}))); - } - _ => start_rename(&ctx, layer.id.0, &layer.name), + match double_click_action(layer) { + Some(a) => actions.push(a), + None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, layer, &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), - (Icon::Collapse, sw.collapse, "collapse", matches!(layer.source, LayerSource::Comp { .. } | LayerSource::Shape | LayerSource::Text)), + (Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)), (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()), (Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())), ( @@ -1902,7 +1928,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions); + layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } // Modes. @@ -1931,7 +1957,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), }; - let tresp = widgets::dropdown(ui, mr, &label, &t, egui::Id::new(("trkmat", layer.id.0))); + // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu. + let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label()))); + let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::new(("trkmat", layer.id.0))); app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte"); let pop = egui::Id::new(("trk-pop", layer.id.0)); if tresp.clicked() { @@ -1951,17 +1979,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { opts.push(k.label().into()); } } - if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) { - if i == 0 { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); - } else if i >= 2 && i < 2 + candidates.len() { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - actions - .push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": candidates[i - 2].id.0, "kind": kind_name(kind)}))); - } else if let Some(k) = MatteKind::ALL.into_iter().find(|k| k.label() == opts[i]) - && let Some(m) = layer.track_matte - { - actions.push(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.layer.0, "kind": kind_name(k)}))); + let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); + if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { + let ids: Vec = candidates.iter().map(|l| l.id).collect(); + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + actions.push(a); } } if layer.track_matte.is_some() { @@ -2084,6 +2106,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } + if body.double_clicked() + && let Some(a) = double_click_action(layer) + { + actions.push(a); + } let shift = ui.input(|i| i.modifiers.shift); if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() { // Shift-click: the layers from the last one clicked to this one, as in @@ -2097,7 +2124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, layer, &mut actions); + layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if resp.dragged() { @@ -2108,14 +2135,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if frames != 0 { acc -= (frames as f64 * fd * pps) as f32; let d = frames as f64 * fd; + // Moving or trimming a selected layer acts on the whole + // selection; each layer trims from its own in/out point. + let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; let params = match kind { - "move" => { - json!({"layers": if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }, "delta": d, "merge": drag_key}) - } - "in" => json!({"layers": [layer.id.0], "in": (layer.in_point.seconds() + d).max(0.0), "merge": drag_key}), - _ => { - json!({"layers": [layer.id.0], "out": (layer.out_point.seconds() + d).min(comp.duration.seconds()), "merge": drag_key}) - } + "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), + "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), + _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}), }; actions.push(("layer.timing".into(), params)); } @@ -2312,7 +2338,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu"); let mut picked: Option<&'static str> = None; - egui::Popup::menu(&lang).show(|ui| { + egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { widgets::menu_scroll(ui, |ui| { @@ -2475,7 +2501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if name_resp.clicked() { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)}))); } - name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); + crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); // Value editors. let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); @@ -2567,7 +2593,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::SetTime(ct)); } } - kresp.context_menu(|ui| { + crate::widgets::context_menu(&kresp, |ui| { for (lbl, cmd, params) in [ ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})), ("-", "", json!({})), @@ -2658,10 +2684,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("timeline.empty", empty_rect, "Right-click to create a layer"); } for (response, area) in [(&outline_empty, "outline"), (&empty, "graph")] { - response.context_menu(|ui| { + crate::widgets::context_menu(response, |ui| { let menu = ui.menu_button(crate::i18n::tr("New"), |ui| { for (key, label, command, params) in [ - ("text", "Text", "layer.newText", json!({"text": "Text"})), + ("text", "Text", "layer.newText", json!({})), ("solid", "Solid…", "layer.newSolid", json!({})), ("light", "Light…", "layer.newLight", json!({})), ("camera", "Camera…", "layer.newCamera", json!({})), @@ -2684,6 +2710,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("comp.settings".into(), json!({}))); ui.close(); } + let response = ui.button(crate::i18n::tr("Open in Essential Graphics")); + app.auto.add(&format!("timeline.context.{area}.essentialGraphics"), response.rect, "Open in Essential Graphics"); + if response.clicked() { + actions.push(("comp.openInEssentialGraphics".into(), json!({}))); + ui.close(); + } }); } if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -3257,6 +3289,11 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("gfill", "Gradient Fill"), ("gstroke", "Gradient Stroke"), ("-", ""), + ("frontColor", "Front: Color"), + ("bevelColor", "Bevel: Color"), + ("sideColor", "Side: Color"), + ("backColor", "Back: Color"), + ("-", ""), ("merge", "Merge Paths"), ("offset", "Offset Paths"), ("pucker", "Pucker & Bloat"), @@ -3396,6 +3433,21 @@ fn text_anim_popups( } } +/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track +/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte +/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). +fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { + if i == 0 { + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + } + if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { + let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); + return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + } + let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; + Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) +} + fn kind_name(k: MatteKind) -> &'static str { match k { MatteKind::Alpha => "alpha", @@ -3442,12 +3494,42 @@ fn prop_menu( item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid})); } -fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { - resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); +/// Whether `layer` shows vector footage (SVG, PDF, AI, EPS). +pub(crate) fn vector_footage_layer(project: &effectcraft_engine::project::Project, layer: &Layer) -> bool { + layer + .source + .item() + .and_then(|item| project.item(item)) + .is_some_and(|item| matches!(&item.kind, effectcraft_engine::project::ItemKind::Footage(footage) if effectcraft_engine::vector::is_vector(footage))) +} + +/// Whether the Collapse Transformations / Continuously Rasterize switch applies: precomps, shape and text +/// layers, and vector footage (SVG, PDF, AI, EPS), which the renderer re-rasterizes at the layer's scale. +fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Project) -> bool { + match &layer.source { + LayerSource::Comp { .. } | LayerSource::Shape | LayerSource::Text => true, + LayerSource::Footage { item } => { + let kind = project.item(*item).map(|i| &i.kind); + matches!(kind, Some(effectcraft_engine::project::ItemKind::Footage(f)) if effectcraft_engine::render::is_vector_footage(f)) + } + _ => false, + } +} + +/// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part +/// of the selection keeps the selection and the menu acts on all of it; right-clicking an +/// unselected layer selects only that layer. +fn layer_context_menu(resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { + let in_selection = selected.contains(&layer.id); + if resp.secondary_clicked() && !in_selection && !layer.switches.locked { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); + } + let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, vector, actions)); } /// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(String, serde_json::Value)>) { +pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { @@ -3457,7 +3539,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S } }; ui.menu_button(crate::i18n::tr("New"), |ui| { - item(ui, "Text", "layer.newText", json!({"text": "Text"})); + item(ui, "Text", "layer.newText", json!({})); item(ui, "Solid…", "app.solidSettings", json!({})); item(ui, "Light…", "layer.newLight", json!({})); item(ui, "Camera…", "layer.newCamera", json!({})); @@ -3466,6 +3548,9 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); + if vector { + item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); + } ui.separator(); ui.menu_button(crate::i18n::tr("Masks"), |ui| { item(ui, "New Mask", "layer.addMask", json!({"layer": id})); @@ -3530,8 +3615,14 @@ fn value_editor( // With several layers selected the edit applies to all of them (#491): a scrub by the same // change, a typed value as it is. let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); + // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the + // selected layers by selection order, the first unchanged and the last by all of it. + let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers)); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + actions.push(( + "prop.set".into(), + json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}), + )) }; let is_3d = layer.is_3d(); let mut x = at.x; @@ -3586,8 +3677,8 @@ fn value_editor( dims.push(r); if let Some(nv) = nv { let mut nc = c.clone(); - // Alt edits one value of a linked pair. - let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; + // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub). + let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { @@ -3671,7 +3762,16 @@ fn value_editor( ); } Value::Gradient(_) => { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text); + // Opens the Gradient Editor at the current time (#612). + let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text); + let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-edit", uid)), Sense::click()); + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); + if resp.clicked() { + actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid}))); + } } Value::Str(s) => { p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim); @@ -3684,6 +3784,23 @@ fn value_editor( mod tests { use super::*; + /// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the + /// CTI, from Start Numbering Frames At); otherwise seconds:frames. + #[test] + fn ruler_labels_follow_the_time_display_style() { + use effectcraft_engine::project::{ProjectSettings, TimeDisplayStyle}; + let mut st = ProjectSettings { time_display: TimeDisplayStyle::Timecode, frame_start: 0, ..ProjectSettings::default() }; + assert_eq!(ruler_label(15, 30, &st), "00:15f"); + assert_eq!(ruler_label(45, 30, &st), "01:15f"); + assert_eq!(ruler_label(30 * 3600 + 31, 30, &st), "60:01:01f"); + st.time_display = TimeDisplayStyle::Frames; + assert_eq!(ruler_label(0, 30, &st), "00000"); + assert_eq!(ruler_label(45, 30, &st), "00045"); + st.frame_start = 1; + assert_eq!(ruler_label(45, 30, &st), "00046"); + assert_eq!(ruler_label(i64::MAX, 30, &st), format!("{:05}", i64::MAX)); + } + /// #468: playback pages the view to the CTI; a scrub past either end scrolls that way. #[test] fn the_view_pages_to_the_cti() { @@ -3698,6 +3815,58 @@ mod tests { assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); } + /// #864: picking only a mode in a layer's Track Matte menu sets a matte (the layer above); + /// it used to close the menu and change nothing. + #[test] + fn a_track_matte_mode_without_a_matte_takes_the_layer_above() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + for name in ["Chain Bottom", "Matte", "Chain Top"] { + s.execute("layer.newSolid", json!({"name": name, "color": "#ffffff", "width": 64, "height": 64})).unwrap(); + } + let find = |s: &effectcraft_engine::Session, n: &str| s.active_comp().unwrap().layers.iter().find(|l| l.name == n).unwrap().clone(); + let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); + let candidates = [top.id, matte.id]; + let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { + let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + s.execute(&id, params) + }; + pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); + let m = find(&s, "Chain Bottom").track_matte.expect("a matte"); + assert_eq!((m.layer, m.kind), (matte.id, MatteKind::AlphaInverted)); + assert!(!find(&s, "Matte").switches.video, "the matte layer is hidden"); + // A mode change keeps the matte layer; a matte change keeps the mode. + pick(&mut s, "Chain Bottom", 6, "Luma Matte").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((matte.id, MatteKind::Luma))); + pick(&mut s, "Chain Bottom", 2, "1. Chain Top").unwrap(); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| (m.layer, m.kind)), Some((top.id, MatteKind::Luma))); + assert!(s.undo()); + assert_eq!(find(&s, "Chain Bottom").track_matte.map(|m| m.layer), Some(matte.id), "one undo step per pick"); + // The top layer has nothing above it: an error for the status bar, not a silent no-op. + assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); + pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); + assert!(find(&s, "Chain Bottom").track_matte.is_none()); + assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + } + + /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. + #[test] + fn collapse_switch_shows_for_vector_footage_only() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Footage, ItemKind, Project}; + let mut p = Project::default(); + let mut layer_for = |name: &str, codec: &str| { + let footage = Footage { path: name.into(), codec: codec.into(), ..Default::default() }; + let item = p.add_item(name, Label::SeaFoam, None, ItemKind::Footage(footage)); + let comp = Comp::new(64, 36, effectcraft_engine::time::FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + effectcraft_engine::project::build::layer(&mut p, &comp, name, LayerSource::Footage { item }, (0, 0), None) + }; + let svg = layer_for("logo.svg", "SVG"); + let png = layer_for("photo.png", "PNG"); + assert!(collapse_applies(&svg, &p)); + assert!(!collapse_applies(&png, &p)); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); @@ -3859,6 +4028,28 @@ mod tests { assert!(rows.iter().any(|r| r.trim() == ">Transform"), "{rows:?}"); } + /// U and UU show Time Remap on a layer with time remapping enabled. + #[test] + fn u_and_uu_reveal_time_remap() { + let mut s = effectcraft_engine::Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 4})).unwrap(); + let nested = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "layer.enableTimeRemap", json!({"layers": [nested]})).unwrap(); + app.session.execute("layer.select", json!({"layers": [nested]})).unwrap(); + for uu in [false, true] { + app.ui.timeline.layer_reveal.clear(); + app.last_reveal = uu.then(|| ("keyframes".into(), f64::MAX)); + crate::menus::invoke(&mut app, &ctx, "anim.reveal", json!({"kind": "keyframes"})).unwrap(); + assert_eq!(app.ui.timeline.layer_reveal.get(&nested), Some(&vec!["props".to_string()]), "UU: {uu}"); + let rows = labels(&app); + let at = rows.iter().position(|r| r == "Inner").unwrap(); + assert_eq!(rows[at + 1].trim(), "Time Remap", "UU: {uu}: {rows:?}"); + } + } + /// Enabling time remapping twirls the layer open on Time Remap (#157); disabling it reveals /// nothing. #[test] diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index e403a9d6..05f719fa 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -5,7 +5,8 @@ //! Revert only asks to discard. Agents running commands with `engine.execute` are never asked. //! //! Automation ids: `dialog.unsaved.save`, `dialog.unsaved.dontSave` (Revert: `dialog.unsaved.revert`), -//! `dialog.unsaved.cancel`. +//! `dialog.unsaved.cancel`. Enter answers Save, Cmd+Backspace (Ctrl elsewhere) Don't Save and, in +//! the Revert prompt, Cmd+R Revert. use crate::i18n::tr; use egui::{Color32, vec2}; @@ -73,6 +74,11 @@ pub fn window_title(app: &EffectcraftApp) -> String { format!("EffectCraft - {doc}{}", if app.session.is_dirty() { " *" } else { "" }) } +/// Whether the prompt is Revert Project (Revert / Cancel) instead of Unsaved Changes. +pub(crate) fn is_revert(app: &EffectcraftApp) -> bool { + app.dialog_state.unsaved.command.as_ref().is_some_and(|(id, _)| id == "file.revert") +} + enum Answer { Save, Discard, @@ -80,11 +86,11 @@ enum Answer { } pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { - let Some((id, _)) = app.dialog_state.unsaved.command.clone() else { + if app.dialog_state.unsaved.command.is_none() { app.dialog = None; return; - }; - let revert = id == "file.revert"; + } + let revert = is_revert(app); let name = project_name(app); let mut answer = None; let title = if revert { "Revert Project" } else { "Unsaved Changes" }; @@ -102,7 +108,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add_space(18.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (label, key) = if revert { (" Revert ", "dialog.unsaved.revert") } else { (" Save ", "dialog.unsaved.save") }; - let r = ui.add(egui::Button::new(egui::RichText::new(label).color(Color32::WHITE)).fill(t.accent)); + // Revert discards work: red, like Delete. + let r = ui.add(egui::Button::new(egui::RichText::new(label).color(Color32::WHITE)).fill(if revert { t.danger } else { t.accent })); app.auto.add(key, r.rect, label.trim()); if r.clicked() { answer = Some(if revert { Answer::Discard } else { Answer::Save }); @@ -126,6 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(Answer::Cancel); } + // Enter is Save, the default button; Cmd+Backspace (Ctrl elsewhere) is Don't Save, as in + // macOS save prompts. Revert discards work, so it takes neither: Cmd+R. + if answer.is_none() && !revert && super::dialogs::ui_enter(ctx) { + answer = Some(Answer::Save); + } + let discard = if revert { egui::Key::R } else { egui::Key::Backspace }; + if answer.is_none() && super::dialogs::ui_command_key(ctx, discard) { + answer = Some(Answer::Discard); + } if let Some(a) = answer { answer_with(app, ctx, a); } @@ -227,6 +243,59 @@ mod tests { assert!(app.dialog.is_none() && app.session.project.comps().count() == 0); } + #[test] + fn cmd_backspace_is_dont_save_but_not_revert() { + let press = |app: &mut EffectcraftApp, ctx: &egui::Context| crate::panels::dialogs::press_key(app, ctx, egui::Key::Backspace, egui::Modifiers::COMMAND); + let (mut app, ctx) = app(); + modified(&mut app); + crate::menus::invoke(&mut app, &ctx, "file.newProject", json!({})).unwrap(); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + press(&mut app, &ctx); + assert!(app.dialog.is_none() && !app.session.is_dirty()); + assert_eq!(app.session.project.comps().count(), 0, "closed without saving"); + // The Revert prompt has no Don't Save. + let dir = std::env::temp_dir().join(format!("ec-unsaved-key-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + modified(&mut app); + app.session.execute("file.saveAs", json!({"path": dir.join("Kept.ecproj").to_string_lossy()})).unwrap(); + app.session.execute("layer.newNull", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.revert", json!({})).unwrap(); + press(&mut app, &ctx); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "not reverted"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn enter_is_save_and_cmd_r_is_revert() { + let press = |app: &mut EffectcraftApp, ctx: &egui::Context| crate::panels::dialogs::press_key(app, ctx, egui::Key::Enter, egui::Modifiers::NONE); + let (mut app, ctx) = app(); + let dir = std::env::temp_dir().join(format!("ec-unsaved-enter-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Kept.ecproj"); + modified(&mut app); + app.session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + // The Revert prompt discards work: Enter leaves it open. + app.session.execute("layer.newNull", json!({})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "file.revert", json!({})).unwrap(); + press(&mut app, &ctx); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "not reverted"); + // Cmd+R is Revert. + crate::panels::dialogs::press_key(&mut app, &ctx, egui::Key::R, egui::Modifiers::COMMAND); + assert!(app.dialog.is_none()); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 0, "reverted"); + app.session.execute("layer.newNull", json!({})).unwrap(); + // Save: the change is written, then the project closes. + crate::menus::invoke(&mut app, &ctx, "file.closeProject", json!({})).unwrap(); + press(&mut app, &ctx); + assert!(app.dialog.is_none()); + assert_eq!(app.session.project.comps().count(), 0, "closed"); + app.session.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(app.session.active_comp().unwrap().layers.len(), 1, "the null was saved"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn save_saves_then_continues_and_revert_only_asks_to_discard() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 4ea3b8c3..6c26c2d3 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -210,6 +210,8 @@ use super::viewer_overlays::{self as ov, VertexHit}; use super::viewer_tools as vt; const HANDLE: f32 = 7.0; +/// Shown when a Roto Brush tool is used in the Composition panel (#753). +const ROTO_HINT: &str = "The Roto Brush works in the Layer panel. Double-click the layer to open it."; thread_local! { /// The 3D view camera of the viewer being drawn (None = the comp's active camera). @@ -527,18 +529,22 @@ fn aux_views( return full; } let gap = 2.0; + let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let (main, aux): (Rect, Vec<(Rect, View3D)>) = if n == 2 { let mid = full.center().x; - (Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), View3D::Top)]) + ( + Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max), + vec![(Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)), views.pane(0))], + ) } else { let c = full.center(); let q = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); ( q(c.x + gap / 2.0, c.y + gap / 2.0, full.max.x, full.max.y), vec![ - (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), View3D::Top), - (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), View3D::Front), - (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), View3D::Right), + (q(full.min.x, full.min.y, c.x - gap / 2.0, c.y - gap / 2.0), views.pane(0)), + (q(c.x + gap / 2.0, full.min.y, full.max.x, c.y - gap / 2.0), views.pane(1)), + (q(full.min.x, c.y + gap / 2.0, c.x - gap / 2.0, full.max.y), views.pane(2)), ], ) }; @@ -547,14 +553,23 @@ fn aux_views( p.rect_filled(full, 0.0, Color32::from_black_alpha(200)); let t = app.session.time(); let (cw, ch) = (comp.width as f32, comp.height as f32); - let views = app.session.state.views3d.get(&cid).cloned().unwrap_or_default(); let share = app.session.state.share_view_options; + // The camera and light wireframes of each view (as in After Effects, where every view of the + // layout shows them), evaluated on a snapshot of the project. + let snap_project = app.session.project.clone(); + let snap_expr = app.session.expr.clone(); + let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let selected = app.session.state.selected_layers.clone(); + let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { p.rect_filled(r, 0.0, bg); let fit = ((r.width() - 20.0) / cw).min((r.height() - 20.0) / ch).max(0.01); let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; - let cam = views.cam(v, comp.width as f64, comp.height as f64).state(); + // The Active Camera pane renders through the comp's own camera; the others through their + // view camera. + let through_active = v == View3D::ActiveCamera; + let cam = if through_active { three_d::active_camera(&ectx) } else { views.cam(v, comp.width as f64, comp.height as f64).state() }; let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -566,7 +581,12 @@ fn aux_views( let tex = match cached { Some((k, tex)) if k == key => tex, _ => { - let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() }; + let opts = effectcraft_engine::render::RenderOpts { + scale, + view: (comp.has_3d() && !through_active).then_some(cam), + draft: true, + ..Default::default() + }; let img = app.session.render(cid, t, opts); let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); @@ -582,13 +602,57 @@ fn aux_views( p.rect_stroke(Rect::from_center_size(cr.center(), cr.size() * k), 0.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Middle); } } + if comp.has_3d() { + let map = ViewerMap { origin: cr.min, zoom: fit, comp: [cw, ch], area: r }; + draw_rigs(app, &p.with_clip_rect(r), &map, &ectx, &selected, cam, through_active, Some(v.id())); + } let label = if comp.has_3d() { v.label() } else { crate::i18n::tr("Active Camera") }; p.text(r.left_bottom() + vec2(8.0, -8.0), Align2::LEFT_BOTTOM, label, Tokens::ui(11.0), Color32::from_white_alpha(200)); app.auto.add(&format!("viewer.view.{}", v.id()), r, label); + if comp.has_3d() + && let Some(chosen) = pane_selector(app, ui, i + 1, r, v) + { + picked = Some((i + 1, chosen)); + } + } + if let Some((pane, v)) = picked + && let Err(e) = app.session.execute("view.setPaneView", json!({"pane": pane, "view": v.id(), "comp": cid.0})) + { + app.ui.status = e.to_string(); } main } +/// The view selector in the top-left corner of pane `pane` (1 to 3) of a multi-view layout, a +/// popup of every 3D view like the toolbar's 3D View menu. Returns the view chosen this frame. +fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option { + let t = app.tokens; + let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0)); + let pid = egui::Id::new(("viewer-pane-view", pane)); + let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid); + if response.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add_response(&format!("viewer.view.{pane}.type"), &response, "View of pane"); + if !widgets::popup_is_open(ui, pid) { + return None; + } + let chosen = widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, View3D::ALL.len(), 0, |ui| { + ui.set_min_width(124.0); + let mut chosen = None; + for (k, v) in View3D::ALL.iter().enumerate() { + let item = ui.selectable_label(*v == current, crate::i18n::tr(v.label())); + app.auto.add(&format!("viewer.view.{pane}.typeItem.{k}"), item.rect, v.label()); + if item.clicked() { + chosen = Some(*v); + } + } + chosen + }); + chosen.filter(|v| *v != current) +} + /// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel /// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { @@ -735,10 +799,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (cw, ch) = (comp.width as f32, comp.height as f32); let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01); + // Fit up to 100% follows the panel's size like Fit does, and stops at 100%. + let fit = if app.ui.viewer.fit_up_to_100 { fit.min(1.0 / ctx.pixels_per_point()) } else { fit }; ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit)); let zoom = app.ui.viewer.zoom.unwrap_or(fit); let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]); - let origin = center - vec2(cw * zoom / 2.0, ch * zoom / 2.0); + // On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel + // instead of being blended with its neighbour (#882). + let ppp = ctx.pixels_per_point(); + let origin = snap_to_pixels(center - vec2(cw * zoom / 2.0, ch * zoom / 2.0), ppp); let map = ViewerMap { origin, zoom, comp: [cw, ch], area }; ctx.data_mut(|d| d.insert_temp(map_id(), map)); let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom)); @@ -748,7 +817,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.viewer.extended = extended_region(app, &comp, cid, map); // Frame. - let ppp = ctx.pixels_per_point(); let scale = app.viewer_scale(zoom, ppp); if !app.playback.playing { // Queue the frame on screen before any prefetch. @@ -1014,8 +1082,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes"); } - super::expr_editor::error_banner(app, ui, area); - // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); @@ -1049,12 +1115,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } + // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer + // instead of leaving a black picture. + let mut notices = 0; + for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { + let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; + let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; + let Some(why) = app.session.footage.problem(f) else { continue }; + if notices >= 3 { + break; + } + let (m, _) = l2c(&ectx, layer); + let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); + let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); + let at = map.to_screen([c.x, c.y]); + let text = format!("{}: {}", layer.name, why); + let galley = painter.layout(text.clone(), Tokens::medium(12.0), Color32::WHITE, (map.area.width() - 40.0).clamp(80.0, 420.0)); + let r = Rect::from_center_size(at + vec2(0.0, notices as f32 * 4.0), galley.size() + vec2(24.0, 16.0)).intersect(map.area); + painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(0x4a, 0x1a, 0x14, 0xe6)); + painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); + painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); + notices += 1; + } + // Free transform box of a path (double-click a path with the Selection tool). let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths); // Cameras and lights (wireframes) and the 3D view name. if comp.has_3d() { - draw_rigs(app, &painter, &map, &ectx, &selected); + let through_active = VIEW_CAM.with(|c| c.get()).is_none(); + draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None); let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(); let label = match (view, comp.active_camera(time)) { (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name), @@ -1117,7 +1208,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let context_layers: Vec = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect(); let mut context_actions = Vec::new(); - resp.context_menu(|ui| { + let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard"); + crate::widgets::context_menu(&resp, |ui| { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { @@ -1135,7 +1227,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if context_layers.is_empty() { ui.label(crate::i18n::tr("No unlocked layer selected")); } else { - super::timeline::layer_menu(ui, &context_layers, &mut context_actions); + let vector = context_layers + .iter() + .filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))) + .any(|layer| super::timeline::vector_footage_layer(&app.session.project, layer)); + super::timeline::layer_menu(ui, &context_layers, vector, &mut context_actions); + } + ui.separator(); + if ui.button(copy_frame).clicked() { + context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0}))); + ui.close(); } }); for (id, params) in context_actions { @@ -1215,7 +1316,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mods = ui.input(|i| i.modifiers); let space_pan = super::space_hand(&ctx); // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the - // first vertex closes the path. + // first vertex closes the path, pressing an open path's last vertex resumes it. if app.ui.tool == Tool::Pen && !space_pan && resp.hovered() @@ -1432,6 +1533,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(Gesture::Marquee { start: press }) } } + // The Roto Brush paints in the Layer panel only: say so instead of doing nothing. + rt if rt.roto_kind(false).is_some() => { + app.ui.status = crate::i18n::tr(ROTO_HINT).to_string(); + None + } _ => None, }; if let Some(g) = g { @@ -1792,6 +1898,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift})); } } + // The Roto Brush paints in the Layer panel only: select as usual and say so. + rt if rt.roto_kind(false).is_some() => { + if pick(app, &ectx, cpt, mods.shift).is_none() { + let _ = app.session.execute("edit.deselectAll", json!({})); + } + app.ui.status = crate::i18n::tr(ROTO_HINT).to_string(); + } _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); @@ -1823,10 +1936,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); - if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) - && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source - { - app.session.open_comp(*item); + let roto = app.ui.tool.roto_kind(false).is_some(); + if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) { + match &l.source { + effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item), + // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel, + // where the brush works (After Effects). + _ if roto => { + let id = l.id.0; + match crate::menus::invoke(app, &ctx, "layer.openLayer", json!({"layer": id})) { + Ok(_) => app.ui.status.clear(), + Err(e) => app.ui.status = e, + } + } + _ => {} + } } } // 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while @@ -1834,6 +1958,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); // Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures). crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); + // Selected shape gradients' start and end points (#612). + crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0); // Status chips (render time / caching). if app.playback.playing && app.playback.waiting { @@ -1844,7 +1970,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { vt::draw_snap(&ctx, &painter, &map, &ectx); vt::rulers(app, ui, &map, outer, area); - vt::bottom_bar(app, ui, bar, zoom, fit, time, &comp); + vt::bottom_bar(app, ui, bar, zoom, time, &comp); if err_h > 0.0 { super::expr_bar::draw(app, ui, err_bar, cid); } @@ -1874,13 +2000,26 @@ fn drag_axes(ctx: &EvalCtx, l: &Layer) -> ([f64; 3], [f64; 3]) { /// Wireframes of cameras (frustum, point of interest) and lights (position, direction) as seen /// through the current 3D view. -fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, selected: &[LayerId]) { +/// Camera and light wireframes seen through `cam` (`through_active`: `cam` is the active camera, +/// which is then not drawn). `pane` names the automation ids: `None` for the main view +/// (`viewer.camera.`), `Some(view)` for a view of a 2- or 4-view layout +/// (`viewer.view..camera.`). +#[allow(clippy::too_many_arguments)] +fn draw_rigs( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + selected: &[LayerId], + cam: CameraState, + through_active: bool, + pane: Option<&str>, +) { use effectcraft_engine::geom::Vec3; use effectcraft_engine::project::{LayerSource, LightKind}; - let cam = cam_state(ectx); let (w, h) = (ectx.comp.width as f64, ectx.comp.height as f64); let active = ectx.comp.active_camera(ectx.time).map(|c| c.id); - let through_active = VIEW_CAM.with(|c| c.get()).is_none(); + let prefix = pane.map(|v| format!("viewer.view.{v}.")).unwrap_or_else(|| "viewer.".to_string()); let proj = |p: Vec3| cam.project(w, h, p).map(|v| map.to_screen([v.x, v.y])); let seg = |a: Vec3, b: Vec3, stroke: Stroke| { if let (Some(x), Some(y)) = (proj(a), proj(b)) { @@ -1917,7 +2056,7 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, } } if let Some(s) = proj(eye) { - app.auto.add(&format!("viewer.camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); + app.auto.add(&format!("{prefix}camera.{}", l.id.0), Rect::from_center_size(s, vec2(14.0, 14.0)), &l.name); } } else { let LayerSource::Light { kind } = l.source else { continue }; @@ -1938,16 +2077,16 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, painter.circle_stroke(q, 3.0, stroke); } } - app.auto.add(&format!("viewer.light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); + app.auto.add(&format!("{prefix}light.{}", l.id.0), Rect::from_center_size(s, vec2(16.0, 16.0)), &l.name); } } } /// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it), -/// add a vertex on a visible path's segment, or start a new path: a mask on the selected -/// footage/solid layer (or shape layer, with Tool Creates Mask), a shape path (Shape n group with -/// the Tools bar's Fill and Stroke) on the selected shape layer, or a new shape layer when -/// nothing is selected. The placed point snaps. +/// resume an open path whose last vertex is pressed, add a vertex on a visible path's segment, or +/// start a new path: a mask on the selected footage/solid layer (or shape layer, with Tool Creates +/// Mask), a shape path (Shape n group with the Tools bar's Fill and Stroke) on the selected shape +/// layer, or a new shape layer when nothing is selected. The placed point snaps. /// Add a Position keyframe at a motion path's frame, keeping the path where it is. fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { @@ -2033,6 +2172,11 @@ fn pen_press( } return; } + // Clicking the last vertex of an open path (e.g. after Esc ended it) resumes drawing it. + if let Some(cur) = ov::open_end_at(paths, map, pos, 8.0) { + ui.data_mut(|d| d.insert_temp(pen_id(), cur)); + return; + } // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. if let Some(f) = motion_frame { add_motion_key(app, f); @@ -2298,7 +2442,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - let p = ui.painter(); + let p = ui.painter().clone(); p.rect_filled(rect, 0.0, t.pasteboard); let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); @@ -2312,6 +2456,36 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); + // A Project item dropped on the empty viewer creates the first composition, + // just like dropping it onto an empty Timeline (#684). Multiple selected + // items use the existing New Comp from Selection dialog. + let ctx = ui.ctx().clone(); + if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = crate::panels::project_drop_items(app, *dragged) + .into_iter() + .map(effectcraft_engine::project::ItemId) + .filter(|id| { + app.session.project.item(*id).is_some_and(|item| match &item.kind { + effectcraft_engine::project::ItemKind::Footage(f) => f.kind != effectcraft_engine::project::FootageKind::Data, + effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true, + _ => false, + }) + }) + .collect(); + if !items.is_empty() { + p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("viewer.newCompDrop", rect, "Drop footage to create a composition"); + if ctx.input(|i| i.pointer.any_released()) { + app.session.state.project_selection = items; + if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { + app.ui.status = e; + } + egui::DragAndDrop::clear_payload(&ctx); + } + } + } } /// `#rrggbb` → sRGB bytes. @@ -2330,21 +2504,32 @@ pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Strok } } -/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. +/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point. +pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 { + if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) { + return p; + } + pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp) +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in +/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels +/// as blocks, never smoothed, in either mode (#882). pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { - if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } + if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST } } /// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters /// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of -/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. +/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both +/// show the nearest pixel (#882). pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; let label = Some("viewer frame"); if smooth { SamplerDescriptor { label, - mag_filter: FilterMode::Linear, + mag_filter: FilterMode::Nearest, min_filter: FilterMode::Linear, mipmap_filter: MipmapFilterMode::Linear, ..Default::default() @@ -2405,9 +2590,9 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: FrameImage::Cpu(img) => { // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); + let fitted = crate::frames::fit_shared_texture_by(&img, max, minify); match &mut app.viewer_tex { - Some((tex, k, m)) if tex.size() == fitted.size => { + Some((tex, k, m)) if tex.size() == fitted.size() => { tex.set(fitted, opts); (*k, *m) = (key, minify); } @@ -2442,6 +2627,35 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps + /// comp pixels 1:1 onto screen pixels. + #[test] + fn frame_origin_snaps_to_physical_pixels() { + for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] { + for p in [pos2(-201.12, 72.4), pos2(689.8, 239.75), pos2(0.3, -0.37)] { + let s = snap_to_pixels(p, ppp); + for v in [s.x * ppp, s.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}: {p:?} → {s:?}"); + } + assert!((s - p).length() <= 0.71 / ppp, "moved by at most half a pixel: {p:?} → {s:?}"); + } + } + assert_eq!(snap_to_pixels(pos2(1.3, 2.0), 0.0), pos2(1.3, 2.0)); + } + + /// #882: a magnified frame shows its pixels as blocks in both Zoom Quality modes; More + /// Accurate only smooths when the frame is shown smaller. + #[test] + fn magnified_frames_are_never_smoothed() { + use eframe::wgpu::FilterMode; + for smooth in [true, false] { + assert_eq!(zoom_texture_options(smooth).magnification, egui::TextureFilter::Nearest); + assert_eq!(zoom_sampler(smooth).mag_filter, FilterMode::Nearest); + } + assert_eq!(zoom_texture_options(true).minification, egui::TextureFilter::Linear); + assert_eq!(zoom_sampler(true).min_filter, FilterMode::Linear); + } + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). fn tilted() -> Mat3 { Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 54218e9f..cc901302 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -87,6 +87,15 @@ pub(crate) fn segment_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f3 best.map(|b| b.1) } +/// The open path whose last vertex lies within `tol` points of `pos`: the Pen resumes it there. +pub(crate) fn open_end_at(paths: &[PathInfo], map: &ViewerMap, pos: Pos2, tol: f32) -> Option<(LayerId, u64)> { + paths.iter().find_map(|p| { + let v = p.sp.vertices.last().filter(|_| !p.sp.closed)?; + let q = p.m.apply(gv2(v[0], v[1])); + (map.to_screen([q.x, q.y]).distance(pos) < tol).then_some((p.layer, p.uid)) + }) +} + /// A mask or shape path of a layer by group uid, evaluated at the context time. pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option { let l = ectx.comp.layer(layer)?; @@ -640,3 +649,26 @@ pub(crate) fn update( Drag::Roi { .. } => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo { + let n = vertices.len(); + let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() }; + PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } + } + + /// #912: clicking the last vertex of an open path with the Pen resumes that path. + #[test] + fn pen_resumes_an_open_path_at_its_last_vertex() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [100.0, 100.0], area: Rect::EVERYTHING }; + let open = info(vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]], false); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(52.0, 49.0), 8.0), Some((LayerId(3), 7))); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(10.0, 10.0), 8.0), None, "first vertex"); + assert_eq!(open_end_at(std::slice::from_ref(&open), &map, egui::pos2(80.0, 80.0), 8.0), None, "far away"); + let closed = info(vec![[10.0, 10.0], [50.0, 10.0], [50.0, 50.0]], true); + assert_eq!(open_end_at(&[closed], &map, egui::pos2(50.0, 50.0), 8.0), None, "closed path"); + } +} diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 36a3c7f2..f4604f2e 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -26,6 +26,7 @@ use serde_json::{Value, json}; use super::viewer::ViewerMap; use crate::EffectcraftApp; +use crate::dock::PanelKind; use crate::state::Tool; /// Pointer interaction owned by text editing (kept across frames while the button is down). @@ -135,6 +136,21 @@ fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option { } } +/// The panels a text edit stays open across: the viewers, and Character, Paragraph and +/// Properties, which format the selected characters. +fn keeps_text_edit(p: PanelKind) -> bool { + super::viewers::id_of(p).is_some() || matches!(p, PanelKind::Character | PanelKind::Paragraph | PanelKind::Properties) +} + +/// A click in panel `p`. Any other panel (the Timeline, the Project panel, …) ends the edit, as a +/// click outside the text does, and gives the keyboard back to the app (#831). +pub fn panel_clicked(app: &mut EffectcraftApp, ctx: &egui::Context, p: PanelKind) { + if app.session.state.text_edit.is_some() && !keeps_text_edit(p) { + exec(app, "text.endEdit", json!({})); + ctx.memory_mut(|m| m.surrender_focus(focus_id())); + } +} + /// Typing merge key: consecutive keystrokes fold into one undo step until the caret moves. fn typing_key(ui: &egui::Ui, bump: bool) -> String { let id = egui::Id::new("viewer-text-typing"); @@ -332,16 +348,18 @@ pub fn hook( ui.memory_mut(|m| { m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); }); - // A session started elsewhere (menu, agent, Properties) takes the keyboard once. + // A session started elsewhere (menu, agent, Properties) takes the keyboard once… let seen = egui::Id::new("viewer-text-seen"); let key = app.session.state.text_edit.as_ref().map(|e| e.layer.0); - // …and gets it back whenever nothing else holds it (after a panel click, say). - if ui.data(|d| d.get_temp::>(seen)).flatten() != key || ui.memory(|m| m.focused().is_none()) { + // …and gets it back when a click in the viewer or a formatting panel left nothing holding + // it; not merely because nothing has the focus (#831). + let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused); + if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain { ui.data_mut(|d| d.insert_temp(seen, key)); ui.memory_mut(|m| m.request_focus(focus_id())); } if ui.memory(|m| m.has_focus(focus_id())) { - keyboard(app, ui); + keyboard(app, ui, resp.rect); } } else { ui.data_mut(|d| d.insert_temp::>(egui::Id::new("viewer-text-seen"), None)); @@ -359,12 +377,16 @@ pub fn hook( owns } -fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui) { +/// This frame's keys, in order, into the edited text, up to a press outside the `viewer`: the +/// panel it lands in decides (after this frame's panels) whether the edit ends, so the keys after +/// it in this frame are neither typed nor run as shortcuts (#831). +fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { let events = ui.input(|i| i.events.clone()); let ctx = ui.ctx().clone(); for ev in events { let Some(sel) = app.session.state.text_edit.clone() else { return }; match ev { + egui::Event::PointerButton { pos, pressed: true, .. } if !viewer.contains(pos) => return, egui::Event::Text(t) if !t.chars().any(char::is_control) => { let k = typing_key(ui, false); exec(app, "text.insert", json!({"text": t, "merge": k})); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 772d0769..fa198505 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -403,7 +403,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: _ => { let img = crate::frames::to_color_image(&s.image); let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) }; - let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR); + let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } @@ -509,6 +510,53 @@ fn toggle_popup(ui: &egui::Ui, id: &str) { ui.data_mut(|d| d.insert_temp(pid, !open)); } +/// A magnification ratio dropdown (the Composition and Layer panels' footers): `zoom` is the +/// shown magnification and `current` the stored one (None = fit), both in points per pixel. +/// Returns the chosen magnification (`Some(None)` = Fit). +#[allow(clippy::too_many_arguments)] +pub(crate) fn magnification_menu( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + r: Rect, + id: &str, + auto_id: &str, + auto_items: &str, + zoom: f32, + current: Option, + fit_up: bool, +) -> Option<(Option, bool)> { + let t = app.tokens; + let ppp = ui.ctx().pixels_per_point(); + let pop = format!("{id}-pop"); + let response = + widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new(id)).on_hover_text(crate::i18n::tr("Magnification ratio popup")); + if response.clicked() { + toggle_popup(ui, &pop); + } + app.auto.add_response(auto_id, &response, "Magnification"); + const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; + let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; + let items: Vec<(String, bool)> = MAGS + .iter() + .map(|m| { + let on = match *m { + "Fit" => current.is_none() && !fit_up, + "Fit up to 100%" => current.is_none() && fit_up, + s => s.trim_end_matches('%').parse::().is_ok_and(|v| current.is_some() && (v - cur).abs() < 0.06), + }; + (m.to_string(), on) + }) + .collect(); + let i = popup(app, ui, &pop, r, &items, auto_items)?; + // `(zoom, fit up to 100 %)`: Fit and Fit up to 100% leave the zoom unset (it follows the + // panel size). + match *MAGS.get(i)? { + "Fit" => Some((None, false)), + "Fit up to 100%" => Some((None, true)), + s => s.trim_end_matches('%').parse::().ok().map(|v| (Some(v / 100.0 / ppp), false)), + } +} + /// Magnification as After Effects shows it: "136.1 %", "100 %". pub(crate) fn magnification_label(pct: f32) -> String { if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") } @@ -538,7 +586,7 @@ pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String { /// transparency grid, mask and shape path visibility, region of interest, grid and guide /// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D /// renderer and view, and the current time. -pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) { +pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) { let t = app.tokens; let p = ui.painter().clone(); p.rect_filled(bar, 0.0, t.panel_bg); @@ -554,7 +602,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, egui::ScrollArea::horizontal().id_salt("viewer-controls").auto_shrink([false, false]).show(ui, |ui| { let row = Rect::from_min_size(ui.max_rect().min, vec2(bar.width(), 30.0)); let first_control = app.auto.elements.len(); - let width = bottom_bar_contents(app, ui, row, zoom, fit, time, comp); + let width = bottom_bar_contents(app, ui, row, zoom, time, comp); ui.allocate_rect(Rect::from_min_size(row.min, vec2(width, row.height())), Sense::hover()); if let Some(focused) = ui.ctx().memory(|memory| memory.focused()) && app.auto.elements.get(first_control..).is_some_and(|controls| controls.iter().any(|control| control.response_id == Some(focused))) @@ -567,7 +615,7 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, }); } -fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) -> f32 { +fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, time: Tick, comp: &Comp) -> f32 { let t = app.tokens; let p = ui.painter().clone(); let cy = bar.center().y; @@ -576,44 +624,24 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z // Magnification. let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0)); - let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag")) - .on_hover_text(crate::i18n::tr("Magnification ratio popup")); - if response.clicked() { - toggle_popup(ui, "vw-mag-pop"); - } - app.auto.add_response("viewer.magnification", &response, "Magnification"); - const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"]; - let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0; - let items: Vec<(String, bool)> = MAGS - .iter() - .map(|m| { - let on = match *m { - "Fit" => app.ui.viewer.zoom.is_none(), - s => s.trim_end_matches('%').parse::().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06), - }; - (m.to_string(), on) - }) - .collect(); - if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") { + let fit_up = app.ui.viewer.fit_up_to_100; + if let Some((z, up)) = magnification_menu(app, ui, r, "vw-mag", "viewer.magnification", "magnification", zoom, app.ui.viewer.zoom, fit_up) { app.ui.viewer.pan = [0.0, 0.0]; - app.ui.viewer.zoom = match MAGS[i] { - "Fit" => None, - "Fit up to 100%" => Some(fit.min(1.0 / ppp)), - s => s.trim_end_matches('%').parse::().ok().map(|v| v / 100.0 / ppp), - }; + app.ui.viewer.fit_up_to_100 = up; + app.ui.viewer.zoom = z; } x = r.max.x + 4.0; // Resolution / down-sample factor. let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0)); let scale = app.viewer_scale(zoom, ppp); - let response = widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res")) + let res = app.viewer_res(); + let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::new("vw-res")) .on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup")); if response.clicked() { toggle_popup(ui, "vw-res-pop"); } app.auto.add_response("viewer.resolution", &response, "Resolution/Down Sample Factor"); - let res = app.ui.viewer.res; let items: Vec<(String, bool)> = vec![ ("Full".into(), res == Resolution::Full), ("Half".into(), res == Resolution::Half), diff --git a/crates/ui-egui/src/panels/waveform.rs b/crates/ui-egui/src/panels/waveform.rs index ab3fbd79..6fc42ed3 100644 --- a/crates/ui-egui/src/panels/waveform.rs +++ b/crates/ui-egui/src/panels/waveform.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{ItemId, ItemKind, Layer, LayerSource, Project}; +use effectcraft_engine::project::{ItemId, ItemKind, Layer, LayerSource, Project, Property}; +use effectcraft_engine::render::audio::db_to_gain; use effectcraft_engine::time::Tick; use egui::{Color32, Painter, Rect, Stroke, pos2}; @@ -75,6 +76,17 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio None } +/// Left/right gain of the layer's Audio Levels (dB) at comp time `t`; unity without the property. +fn level_gains(levels: Option<&Property>, t: Tick) -> [f32; 2] { + match levels { + Some(p) => { + let [l, r] = p.value_at(t).as_vec2(); + [db_to_gain(l), db_to_gain(r)] + } + None => [1.0, 1.0], + } +} + /// Draw `layer`'s waveform in `rect` (left channel above right), columns mapped through the /// timeline's time map and the layer's time mapping, limited to its [in, out) span. pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summary, color: Color32) { @@ -83,6 +95,7 @@ pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summar let x0 = tm.x(layer.in_point.seconds()).max(rect.min.x).floor() as i32; let x1 = tm.x(layer.out_point.seconds()).min(rect.max.x).ceil() as i32; let n = s.peaks.len() as i64; + let levels = layer.props.sub("audio").and_then(|g| g.get("levels")); for c in 0..2 { p.line_segment([pos2(x0 as f32, mid[c]), pos2(x1 as f32, mid[c])], Stroke::new(1.0, color.gamma_multiply(0.5))); } @@ -101,10 +114,26 @@ pub(crate) fn draw(p: &Painter, rect: Rect, tm: &TMap, layer: &Layer, s: &Summar agg[2] = agg[2].min(b[2]); agg[3] = agg[3].max(b[3]); } + let gain = level_gains(levels, Tick::from_seconds_f64((ta + tb) * 0.5)); for c in 0..2 { - let (lo, hi) = (agg[c * 2].clamp(-1.0, 1.0), agg[c * 2 + 1].clamp(-1.0, 1.0)); + let (lo, hi) = ((agg[c * 2] * gain[c]).clamp(-1.0, 1.0), (agg[c * 2 + 1] * gain[c]).clamp(-1.0, 1.0)); let (ya, yb) = (mid[c] - hi * half, mid[c] - lo * half); p.line_segment([pos2(x as f32 + 0.5, ya), pos2(x as f32 + 0.5, yb.max(ya + 1.0))], Stroke::new(1.0, color)); } } } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::project::Value; + + #[test] + fn waveform_follows_audio_levels() { + let p = Property::new(1, "levels", "Audio Levels", Value::Vec2([-6.0206, -96.0])); + let g = level_gains(Some(&p), Tick::ZERO); + assert!((g[0] - 0.5).abs() < 1e-3, "{}", g[0]); + assert_eq!(g[1], 0.0); + assert_eq!(level_gains(None, Tick::ZERO), [1.0, 1.0]); + } +} diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index b18d3e87..321e432e 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -91,14 +91,19 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { // Dropped files import as stills and folders' numbered stills as sequences; Alt-drag // imports a folder's files one by one (After Effects). let alt = ctx.input(|i| i.modifiers.alt); + // A dropped Photoshop file asks how to import it (the Import Photoshop File dialog, its + // Import Kind starting at Default Drag Import As), as File ▸ Import ▸ File… does (#678). + let layered = rest.iter().any(|p| p.rsplit('.').next().is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "psd" | "psb"))); let mut params = json!({ "paths": rest, "drag": true, - "importAs": app.session.prefs.drag_import_as(), "addToComp": at.is_some(), "position": at, "background": true }); + if !layered { + params["importAs"] = json!(app.session.prefs.drag_import_as()); + } if alt { params["sequence"] = json!(false); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7c49c786..1bdfdd5a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -229,58 +229,19 @@ impl Tool { } } -/// Viewer resolution (Auto follows the magnification). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Resolution { - #[default] - Auto, - Full, - Half, - Third, - Quarter, - /// View ▸ Resolution ▸ Custom: render every n-th pixel (2..=40). - Custom(u8), -} - -impl Resolution { - pub const ALL: [Resolution; 5] = [Resolution::Auto, Resolution::Full, Resolution::Half, Resolution::Third, Resolution::Quarter]; - pub fn label(self) -> &'static str { - match self { - Resolution::Auto => "Auto", - Resolution::Full => "Full", - Resolution::Half => "Half", - Resolution::Third => "Third", - Resolution::Quarter => "Quarter", - Resolution::Custom(_) => "Custom", - } - } - /// Render scale for a viewer magnification (and display pixel density). - pub fn scale(self, zoom: f32, ppp: f32) -> f64 { - match self { - // As in After Effects, Auto renders the pixels the magnification needs: the coarsest - // whole factor that still gives every screen pixel a rendered one (Full above 50 %, - // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). - Resolution::Auto => { - let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); - if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } - } - Resolution::Full => 1.0, - Resolution::Half => 0.5, - Resolution::Third => 1.0 / 3.0, - Resolution::Quarter => 0.25, - Resolution::Custom(n) => 1.0 / n.max(1) as f64, - } - } -} +/// The viewer's Resolution: each composition keeps its own in the project +/// ([`effectcraft_engine::project::Comp::resolution`], `EffectcraftApp::viewer_res`). +pub use effectcraft_engine::project::Resolution; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct ViewerState { /// Magnification (1 = 100%); None = fit. pub zoom: Option, + /// Fit up to 100%: while fitting (`zoom` is None), the comp is never magnified past 100%. + pub fit_up_to_100: bool, /// Pan offset in points from the centred position. pub pan: [f32; 2], - pub res: Resolution, pub transparency_grid: bool, pub show_masks: bool, pub safe_margins: bool, @@ -318,8 +279,8 @@ impl Default for ViewerState { fn default() -> Self { ViewerState { zoom: None, + fit_up_to_100: false, pan: [0.0, 0.0], - res: Resolution::Auto, transparency_grid: false, show_masks: true, safe_margins: false, @@ -493,6 +454,15 @@ pub struct FxPick { pub name: String, } +/// A Character panel Fill or Stroke eyedropper waiting for a Composition viewer click. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextColorPick { + pub layer: u64, + pub field: String, + /// Preserve the edited character range when focus moves from the panel to the viewer. + pub range: Option<[usize; 2]>, +} + /// Effects & Presets contents-menu options. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EffectsView { @@ -571,6 +541,10 @@ pub struct UiState { /// Floating panels of the saved workspaces. #[serde(default)] pub saved_floating: std::collections::BTreeMap>, + /// Floating groups shown in their own native window instead of over the dock (Move Panel + /// to New Window), so they can be placed on another monitor. + #[serde(default)] + pub panel_windows: Vec, /// The panel maximized to fill the dock area (`~`), if any. #[serde(default)] pub maximized: Option, @@ -620,6 +594,9 @@ pub struct UiState { /// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click. #[serde(default)] pub fx_pick: Option, + /// Character panel colour eyedropper waiting for a viewer click. + #[serde(default)] + pub text_color_pick: Option, /// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid. #[serde(default)] pub fx_curve_channel: BTreeMap, @@ -660,6 +637,14 @@ pub struct UiState { /// Layer panel View: number of effects rendered (None = through the last Paint effect). #[serde(default)] pub layer_view: Option, + /// Layer panel magnification in points per layer pixel (None = fit), set by its + /// magnification menu, View ▸ Zoom In / Zoom Out while it is focused, the mouse wheel and the + /// Zoom tool. + #[serde(default)] + pub layer_zoom: Option, + /// Layer panel pan offset in points from the centred position (Hand tool, Space-drag). + #[serde(default)] + pub layer_pan: [f32; 2], /// Wiggler / Smoother / Motion Sketch settings. #[serde(default)] pub anim_tools: AnimToolsState, @@ -685,6 +670,7 @@ impl Default for UiState { saved_workspaces: Default::default(), floating: vec![], saved_floating: Default::default(), + panel_windows: vec![], maximized: None, locked_tabs: BTreeSet::new(), viewers: Default::default(), @@ -707,6 +693,7 @@ impl Default for UiState { fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), fx_pick: None, + text_color_pick: None, fx_curve_channel: BTreeMap::new(), fx_curve_pencil: BTreeSet::new(), fx_levels_channel: BTreeMap::new(), @@ -722,6 +709,8 @@ impl Default for UiState { tracker_source: None, layer_panel: None, layer_view: None, + layer_zoom: None, + layer_pan: [0.0, 0.0], anim_tools: AnimToolsState::default(), mini_flowchart: None, flowchart: Default::default(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 953bf66d..abc6f576 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -340,6 +340,13 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) { fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]); + // Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which + // would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack. + for (family, stack) in &mut fonts.families { + if *family != FontFamily::Monospace { + stack.push("jbmono".into()); + } + } install_script_fallbacks(&mut fonts, language); ctx.set_fonts(fonts); apply_visuals(ctx, t); @@ -649,6 +656,19 @@ mod tests { install_script_fallbacks(&mut english, "en"); } + #[test] + fn interface_symbols_have_a_glyph_in_every_family() { + let ctx = egui::Context::default(); + install(&ctx, &Tokens::for_kind(Default::default()), "en"); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] { + let id = FontId::new(12.0, family); + for c in "▸•›·▶◀●○⚠…×".chars() { + assert!(ctx.fonts_mut(|f| f.has_glyph(&id, c)), "{c} has no glyph in {id:?}"); + } + } + } + #[test] fn hangul_gets_a_face_when_one_is_installed() { use effectcraft_text::fonts; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 18c6aedb..c005224d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -121,7 +121,7 @@ pub fn hot_number_at( let mult = ui.input(|i| { if i.modifiers.shift { 10.0 - } else if i.modifiers.command { + } else if i.modifiers.command && !proportional_scrub(i.modifiers) { 0.1 } else { 1.0 @@ -138,6 +138,13 @@ pub fn hot_number_at( (rect, out, resp.drag_stopped()) } +/// Ctrl+Alt (Cmd+Opt on macOS) held while scrubbing hot text: After Effects' Proportional +/// Scrubbing, which spreads the change over the selected layers by selection order. Ctrl alone +/// still scrubs finely and Shift by tens. +pub fn proportional_scrub(m: egui::Modifiers) -> bool { + m.command && m.alt +} + /// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every /// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames), /// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished). @@ -187,6 +194,24 @@ pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { } } +/// Put the keyboard on a dialog's first text field the frame the dialog opens, with its whole +/// `text` selected, so typing replaces the value without a click. Unlike [`keep_focus`] the +/// focus is the user's afterwards. +pub fn focus_on_open(ctx: &egui::Context, field: &Response, text: &str) { + // The dialog is open in consecutive passes; a gap means it was closed and opened again. + let shown = field.id.with("focus-on-open-pass"); + let pass = ctx.cumulative_pass_nr(); + let previous = ctx.data_mut(|d| { + let previous = d.get_temp::(shown); + d.insert_temp(shown, pass); + previous + }); + if previous.is_none_or(|p| p.saturating_add(1) < pass) { + select_all(ctx, field.id, text); + field.request_focus(); + } +} + /// Select all of the text field `id`'s `text`, so typing replaces it. pub fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) { let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); @@ -307,6 +332,11 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea /// Dropdown-looking control ("Full ▾"). pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -> Response { + dropdown_tip(ui, rect, text, None, t, id) +} + +/// [`dropdown`] with a tooltip `tip` (shown always; it replaces the elided text's tooltip). +pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &Tokens, id: egui::Id) -> Response { let resp = ui.interact(rect, id, Sense::click()); ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); @@ -328,6 +358,9 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) - }; ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + if let Some(tip) = tip { + return resp.on_hover_text(tip.to_string()); + } if text.chars().count() > 0 && !fits(text) { return resp.on_hover_text(text.to_string()); } @@ -579,6 +612,27 @@ pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner } +/// egui's menu style with no gap between the rows: the gap the app's item spacing left between +/// two entries highlighted neither and swallowed a click there. The rows grow by that gap, so a +/// menu keeps its size. +pub fn menu_style(style: &mut egui::Style) { + egui::containers::menu::menu_style(style); + style.spacing.interact_size.y += style.spacing.item_spacing.y; + style.spacing.item_spacing.y = 0.0; +} + +/// `resp`'s context menu in the app's [`menu_style`] (its submenus inherit it). +pub fn context_menu(resp: &Response, add_contents: impl FnOnce(&mut Ui)) -> Option> { + egui::Popup::context_menu(resp).style(menu_style).show(add_contents) +} + +/// A button opening a menu in the app's [`menu_style`], for one outside the menu bar. +pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, add_contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + let config = egui::containers::menu::MenuConfig::new().style(menu_style); + let (response, inner) = egui::containers::menu::MenuButton::new(atoms).config(config).ui(ui, add_contents); + egui::InnerResponse::new(inner.map(|i| i.inner), response) +} + /// Whether a [`popup_menu`] is open (build its options only then: they can be long). pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false)) diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 9262266d..b7233a80 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use effectcraft_engine::config::ConfigStore; use serde::{Deserialize, Serialize}; -use crate::dock::{DockNode, Floating, PanelKind, SplitSize}; +use crate::dock::{DockNode, Floating, PanelKind, PanelWindow, SplitSize}; use crate::state::UiState; const FILE: &str = "workspaces.json"; @@ -28,6 +28,9 @@ struct Snapshot { maximized: Option, saved_workspaces: BTreeMap, saved_floating: BTreeMap>, + /// Floating groups in their own native window (absent from files of earlier versions). + #[serde(default)] + panel_windows: Vec, } impl Snapshot { @@ -41,6 +44,7 @@ impl Snapshot { maximized: ui.maximized, saved_workspaces: ui.saved_workspaces.clone(), saved_floating: ui.saved_floating.clone(), + panel_windows: ui.panel_windows.clone(), } } @@ -52,6 +56,14 @@ impl Snapshot { ui.maximized = self.maximized; ui.saved_workspaces = self.saved_workspaces; ui.saved_floating = self.saved_floating; + // A window is only kept for a panel that still floats, and only where a window can be. + let floating = |panel: PanelKind| ui.floating.iter().any(|group| group.panels.contains(&panel)); + ui.panel_windows = self + .panel_windows + .into_iter() + .filter(|window| floating(window.panel)) + .map(|window| PanelWindow { panel: window.panel, rect: PanelWindow::sanitized(window.rect) }) + .collect(); // Reserve restored viewer slots without restoring any project-specific composition // links. New Viewer allocates from this map, including after switching named layouts. let mut panels = Vec::new(); @@ -89,7 +101,7 @@ impl Snapshot { PanelKind::Viewer(id) => (1..=MAX_VIEWER_ID).contains(&id), _ => true, }; - if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) { + if !panel_ok(self.focused) || self.maximized.is_some_and(|panel| !panel_ok(panel)) || self.panel_windows.iter().any(|w| !panel_ok(w.panel)) { return Err("workspace viewer ID is outside the supported range 1–65535".into()); } let mut nodes = 0usize; @@ -134,6 +146,9 @@ impl Snapshot { } } } + if self.panel_windows.len() > MAX_PANELS || self.panel_windows.iter().any(|w| !w.rect.iter().all(|v| v.is_finite())) { + return Err("invalid workspace panel windows".into()); + } for groups in std::iter::once(&self.floating).chain(self.saved_floating.values()) { if groups.len() > MAX_PANELS { return Err("too many floating workspace groups".into()); @@ -229,6 +244,7 @@ impl WorkspaceStore { || self.saved.maximized != ui.maximized || self.saved.saved_workspaces != ui.saved_workspaces || self.saved.saved_floating != ui.saved_floating + || self.saved.panel_windows != ui.panel_windows } pub(crate) fn save(&mut self, config: Option<&dyn ConfigStore>, ui: &UiState) -> Result { diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 0c65084b..876035a9 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -3,6 +3,7 @@ //! --test ui_3d -- --ignored`); the other test only runs the UI logic. use effectcraft_engine::Session; +use effectcraft_engine::render::three_d::View3D; use effectcraft_ui_egui::EffectcraftApp; use egui_kittest::Harness; use serde_json::json; @@ -195,3 +196,72 @@ fn extended_viewer_renders_past_the_comp_frame() { h.step(); assert!(h.state().ui.viewer.extended.is_none()); } + +/// #875: the Top / Front / Right views of a 4-view layout draw the camera wireframe (they drew +/// none, so moving the camera showed nothing there), and it follows the camera's Position. +#[test] +fn layout_views_draw_the_camera_and_follow_its_position() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("view.layout", json!({"views": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + let centre = |h: &Harness<'_, EffectcraftApp>, view: &str| { + let id = format!("viewer.view.{view}.camera.{cam}"); + let [x, y, w, hh] = h.state().auto.find(&id).unwrap_or_else(|| panic!("{id} is registered")).rect; + (x + w / 2.0, y + hh / 2.0) + }; + for v in ["top", "front", "right"] { + centre(&h, v); + } + let before = centre(&h, "top"); + h.state_mut().session.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [480, 180, -300]})).unwrap(); + settle(&mut h); + let after = centre(&h, "top"); + assert!(after.0 - before.0 > 10.0, "the camera moves right in the Top view: {before:?} -> {after:?}"); + assert!((after.1 - before.1).abs() < 1.0, "{before:?} -> {after:?}"); +} + +/// A 4-view layout over a comp with a 3D solid and a camera. +fn four_views() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layer": solid, "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.newCamera", json!({"position": [320, 180, -300], "poi": [320, 180, 0]})).unwrap(); + s.execute("view.layout", json!({"views": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + h +} + +/// #670: every pane of a multi-view layout has its own view selector; choosing a view changes +/// that pane (its camera and its wireframes) and no other, and it is remembered for the comp. +#[test] +fn each_layout_pane_chooses_its_own_view() { + let mut h = four_views(); + let present = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.find(&format!("viewer.view.{view}")).is_some(); + let has_camera = |h: &Harness<'_, EffectcraftApp>, view: &str| h.state().auto.query(&format!("viewer.view.{view}.camera.")).len() == 1; + for p in 1..=3 { + assert!(h.state().auto.find(&format!("viewer.view.{p}.type")).is_some(), "pane {p} has a selector"); + } + assert!(present(&h, "front") && has_camera(&h, "front")); + // Pane 2 (the Front view) becomes the Left view. + let left = View3D::ALL.iter().position(|v| *v == View3D::Left).unwrap(); + click_id(&mut h, "viewer.view.2.type"); + click_id(&mut h, &format!("viewer.view.2.typeItem.{left}")); + settle(&mut h); + assert_eq!(h.state().auto.find("viewer.view.2.type").unwrap().label, "View of pane"); + assert!(present(&h, "left") && has_camera(&h, "left"), "pane 2 shows the Left view with its wireframe"); + assert!(!present(&h, "front"), "pane 2 no longer shows Front"); + assert!(present(&h, "top") && present(&h, "right"), "the other panes keep their views"); + let cid = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.state.views3d[&cid].pane(1), View3D::Left); + // The Active Camera view of a pane does not draw the camera it looks through. + h.state_mut().session.execute("view.setPaneView", json!({"pane": 1, "view": "activeCamera"})).unwrap(); + settle(&mut h); + assert!(present(&h, "activeCamera") && !has_camera(&h, "activeCamera")); +} diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 561f50a0..fcbf3d09 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,6 +1,6 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; -use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable as _}; use serde_json::json; @@ -132,6 +132,23 @@ fn timeline_blank_menu_opens_composition_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); } +#[test] +fn timeline_blank_menu_opens_current_comp_in_essential_graphics() { + for graph in [false, true] { + let mut h = harness(); + let comp = h.state().session.active_comp_id().unwrap(); + let area = rect(&h, "timeline.layerMarquee"); + let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() }; + right_click(&mut h, p); + let target = if graph { "timeline.context.graph.essentialGraphics" } else { "timeline.context.outline.essentialGraphics" }; + assert!(h.state().auto.find(target).is_some()); + h.get_by_label("Open in Essential Graphics").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.essential_primary, Some(comp)); + assert!(h.state().ui.dock.contains(PanelKind::EssentialGraphics)); + } +} + /// #407: right-clicking a property's name in the Timeline opens After Effects' property menu: /// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or /// Remove Expression and Add Property to Essential Graphics. @@ -195,3 +212,17 @@ fn timeline_property_names_have_the_property_menu() { choose(&mut h, scale, "editValue"); assert_eq!(h.state().dialog, Some(Dialog::Form)); } + +/// The rows of a context menu touch: a gap between two entries highlighted neither and +/// swallowed a click there. +#[test] +fn context_menu_rows_leave_no_gap_between_them() { + let mut h = harness(); + let p = rect(&h, "project.empty").center(); + right_click(&mut h, p); + let rows = ["newComp", "newFolder", "import"].map(|key| rect(&h, &format!("project.context.{key}"))); + for pair in rows.windows(2) { + assert!((pair[1].top() - pair[0].bottom()).abs() < 0.5, "gap between {:?} and {:?}", pair[0], pair[1]); + } + snapshot(&mut h, "project-rows"); +} diff --git a/crates/ui-egui/tests/ui_character_eyedropper.rs b/crates/ui-egui/tests/ui_character_eyedropper.rs new file mode 100644 index 00000000..a2c4c6d1 --- /dev/null +++ b/crates/ui-egui/tests/ui_character_eyedropper.rs @@ -0,0 +1,96 @@ +//! Character Fill and Stroke can sample a displayed Composition pixel. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{ + EffectcraftApp, + dock::{DockNode, PanelKind, SplitSize}, + panels::viewer::comp_to_screen, +}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn click_auto(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + click(h, pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5)); +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + h.run_steps(4); +} + +#[test] +fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Background", "color": "#20c040"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": text})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.set_workspace("Text"); + let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + settle(&mut h); + click_auto(&mut h, "character.fill.eyedropper"); + assert_eq!(h.state().ui.text_color_pick.as_ref().unwrap().range, Some([0, 5])); + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + assert!(h.state().ui.text_color_pick.is_none()); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x20 as f32 / 255.0, 0xc0 as f32 / 255.0, 0x40 as f32 / 255.0]; + for (actual, expected) in doc.style_at(0).fill.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } + assert_ne!(doc.style_at(8).fill, doc.style_at(0).fill, "unselected text keeps its colour"); +} + +#[test] +fn character_stroke_eyedropper_samples_viewer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "brat", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0], "size": 32.0})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedB(360.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Character], active: 0 }), + }; + let builder = Harness::builder().with_size(vec2(1200.0, 760.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + h.state_mut().show_panel(PanelKind::Composition); + settle(&mut h); + click_auto(&mut h, "character.stroke.eyedropper"); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); + } + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x8a as f32 / 255.0, 0xce as f32 / 255.0, 0.0]; + for (actual, expected) in doc.stroke.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } +} diff --git a/crates/ui-egui/tests/ui_comp_presets.rs b/crates/ui-egui/tests/ui_comp_presets.rs new file mode 100644 index 00000000..0b0b3072 --- /dev/null +++ b/crates/ui-egui/tests/ui_comp_presets.rs @@ -0,0 +1,85 @@ +//! Composition Settings: saving, picking and deleting your own presets (egui_kittest). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Edit", "width": 2560, "height": 1440, "frameRate": 60, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + // The automation registry still holds the previous frame's elements: one more frame. + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn open_settings(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "comp.settings", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); +} + +fn preset_names(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.prefs.comp_presets.iter().map(|q| q.name.clone()).collect() +} + +/// Save the comp's size and rate as a preset (Enter saves without pressing OK), then delete it. +#[test] +fn composition_settings_save_and_delete_your_own_preset() { + let mut h = harness(); + open_settings(&mut h); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none(), "nothing of yours to delete yet"); + click(&mut h, "dialog.comp.presetSave"); + // Choose Name opens with the keyboard in its field. + h.input_mut().events.push(Event::Text("1440p 60".into())); + h.run_steps(2); + key(&mut h, Key::Enter); + assert_eq!(preset_names(&h), ["1440p 60"]); + let q = &h.state().session.prefs.comp_presets[0]; + assert_eq!((q.width, q.height, q.pixel_aspect, q.frame_rate), (2560, 1440, 1.0, 60.0)); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings), "Enter in the name field doesn't press OK"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none(), "the name row closes"); + // The comp's values are now that preset, so it can be deleted. + click(&mut h, "dialog.comp.presetDelete"); + assert!(preset_names(&h).is_empty()); + assert!(h.state().auto.find("dialog.comp.presetDelete").is_none()); +} + +/// A built-in preset's name can't be taken; Cancel closes the name row without saving. +#[test] +fn a_preset_name_cannot_be_a_built_in_one() { + let mut h = harness(); + open_settings(&mut h); + click(&mut h, "dialog.comp.presetSave"); + click(&mut h, "dialog.comp.presetName"); + h.input_mut().events.push(Event::Text("HDTV 1080 24".into())); + h.run_steps(2); + click(&mut h, "dialog.comp.presetNameSave"); + assert!(preset_names(&h).is_empty(), "the built-in name is refused"); + click(&mut h, "dialog.comp.presetNameCancel"); + assert!(h.state().auto.find("dialog.comp.presetName").is_none()); + assert!(preset_names(&h).is_empty()); +} diff --git a/crates/ui-egui/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/ui_control_modifiers.rs new file mode 100644 index 00000000..46567aed --- /dev/null +++ b/crates/ui-egui/tests/ui_control_modifiers.rs @@ -0,0 +1,62 @@ +//! Control-channel clicks and drags hold their modifiers for the whole gesture: egui takes +//! `i.modifiers` from `ModifiersChanged` events, not from the pointer events, and synthetic +//! events arrive one per frame, so a Shift-click must hold Shift from its press to its release. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::control::{self, ControlRequest}; +use serde_json::{Value, json}; + +/// Feed the queued synthetic input frame by frame; per frame: (has a press/release, the +/// modifiers egui holds in that frame, which only `ModifiersChanged` events change). +fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> { + let mut out = vec![]; + let mut held = egui::Modifiers::NONE; + for _ in 0..64 { + let mut raw = egui::RawInput::default(); + app.feed_synthetic(&mut raw); + if raw.events.is_empty() { + break; + } + let mut button = false; + for e in &raw.events { + match e { + egui::Event::ModifiersChanged(m) => held = *m, + egui::Event::PointerButton { .. } => button = true, + _ => {} + } + } + out.push((button, held)); + } + out +} + +fn send(app: &mut EffectcraftApp, method: &str, params: Value) { + let ctx = egui::Context::default(); + let (req, _rx) = ControlRequest::new(method, params); + let _ = control::handle(app, &ctx, &req); +} + +#[test] +fn shift_click_holds_shift_from_press_to_release() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "modifiers": {"shift": true}})); + let f = frames(&mut app); + let buttons: Vec<_> = f.iter().filter(|(b, _)| *b).collect(); + assert_eq!(buttons.len(), 2, "press and release: {f:?}"); + assert!(buttons.iter().all(|(_, m)| m.shift), "Shift on the press and the release: {f:?}"); + // Afterwards the modifiers are released again. + send(&mut app, "ui.click", json!({"x": 10, "y": 10})); + assert!(frames(&mut app).iter().all(|(_, m)| !m.shift), "a plain click after it has no Shift"); +} + +#[test] +fn shift_drag_holds_shift_through_the_moves() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": 4, "modifiers": {"shift": true}})); + let f = frames(&mut app); + let first = f.iter().position(|(b, _)| *b).expect("press"); + let last = f.iter().rposition(|(b, _)| *b).expect("release"); + assert!(last > first + 1, "moves between press and release: {f:?}"); + assert!(f[first..=last].iter().all(|(_, m)| m.shift), "Shift held for the whole drag: {f:?}"); +} diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index ce09d7fd..77a3995d 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -15,6 +15,7 @@ use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::panels::viewer::{comp_to_screen, last_fit}; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; /// Comp "Main" (4 s at 30 fps, current time 1 s) with solids Top, Middle and Bottom, and comp @@ -252,6 +253,48 @@ fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps( assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); } +/// #684: dropping imported footage onto the empty Composition viewer creates +/// a composition that matches the source, with that source as its first layer. +#[test] +fn project_footage_dropped_on_empty_viewer_creates_first_comp() { + let mut s = Session::default(); + let footage = + s.execute("file.importPlaceholder", json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5})).unwrap()["item"] + .as_u64() + .unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.active_comp().is_none()); + let from = rect(&h, &format!("project.item.{footage}.name")).center(); + let viewer = rect(&h, "panel.Composition"); + drag(&mut h, from, pos2(viewer.min.x + 40.0, viewer.min.y + 70.0), Modifiers::NONE); + let comp = h.state().session.active_comp().expect("created a composition"); + assert_eq!((comp.width, comp.height), (640, 360)); + assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0)); + assert_eq!(comp.duration.seconds(), 5.0); + assert_eq!(comp.layers.len(), 1); + assert_eq!(comp.layers[0].name, "Landscape.png"); + assert!(h.state().dialog.is_none()); +} + +/// Several dragged sources must ask whether to create one or multiple comps, +/// rather than silently discarding the other selected items. +#[test] +fn multiple_selected_footage_dropped_on_empty_viewer_asks_how_to_create_comps() { + let mut s = Session::default(); + let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); + let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_engine::project::ItemId(first), effectcraft_engine::project::ItemId(second)]; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = rect(&h, &format!("project.item.{first}.name")).center(); + let viewer = rect(&h, "panel.Composition"); + drag(&mut h, from, pos2(viewer.min.x + 40.0, viewer.min.y + 70.0), Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some()); + assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); +} + /// A file dropped on the window (egui hands it over as a dropped file). #[derive(Debug)] struct Dropped(std::path::PathBuf); @@ -379,6 +422,35 @@ fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #678: a dropped Photoshop file asks how to import it, as File ▸ Import ▸ File… does; the +/// Import Kind starts at Settings ▸ Import ▸ Default Drag Import As, and OK imports the drop. +#[test] +fn dropped_photoshop_file_shows_the_import_photoshop_file_dialog() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + h.state_mut().session.prefs.import.drag_import_as = "compLayerSizes".into(); + h.event(Event::PointerMoved(rect(&h, "panel.Project").center())); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("Layers.PSD")))); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the drop asks first"); + h.run_steps(2); + assert!(h.query_by_label("Import Photoshop File").is_some()); + assert!(h.query_by_value("Composition - Retain Layer Sizes").is_some(), "Import Kind starts at Default Drag Import As"); + assert!(!h.state().session.project.items.values().any(|i| i.name == "Layers.PSD"), "nothing imported before OK"); + click_id(&mut h, "form.cancel"); + assert_eq!(h.state().dialog, None); + + // With Default Drag Import As at Footage, OK imports the file as one item. + h.state_mut().session.prefs.import.drag_import_as = "footage".into(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("Layers.PSD")))); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.query_by_value("Footage").is_some()); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.project.items.values().any(|i| i.name == "Layers.PSD")); +} + #[test] fn an_unsupported_drop_reports_the_error_and_keeps_valid_files() { let (mut h, _) = harness(); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 551f8ad4..3251aed0 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -528,7 +528,7 @@ fn radial_center_stays_draggable_when_its_property_is_selected() { #[test] fn clicking_an_effect_parameter_activates_its_header_and_handles() { let (app, x) = app(); - let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); settle(&mut h); let name = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center(); click(&mut h, name); @@ -656,3 +656,55 @@ fn file_and_text_parameters_have_widgets() { assert_eq!(v, KV::Str("left*".into())); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn effect_control_name_can_be_renamed_from_its_header() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + settle(&mut h); + let name = format!("effectControls.effect.{}.name", x.point_fx); + let point = rect_of(&h, &name).center(); + double_click(&mut h, point); + h.run_steps(3); + assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Speed".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let current_name = + |h: &Harness<'_, EffectcraftApp>| layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); + assert_eq!(current_name(&h), "Speed"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(current_name(&h), "Point Control"); +} + +#[test] +fn effect_rename_escape_keeps_the_name() { + let (app, x) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app); + settle(&mut h); + let point = rect_of(&h, &format!("effectControls.effect.{}.name", x.point_fx)).center(); + double_click(&mut h, point); + h.run_steps(3); + assert!(ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Speed".into())); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let name = layer(&h.state().session, x.small).effects().unwrap().groups().find(|g| g.uid == x.point_fx).unwrap().name.clone(); + assert_eq!(name, "Point Control"); + assert!(!ids(&h).contains(&format!("effectControls.effect.{}.nameEdit", x.point_fx))); +} + +/// Two presses within egui's double-click time (one frame apart). +fn double_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + h.run_steps(2); +} diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index e3da6ef9..6be69cb7 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -105,3 +105,74 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { assert_eq!(doc.style_at(0).font, "Noto Serif"); assert_eq!(doc.style_at(8).font, "Inter"); } + +/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at +/// 400: a variable font as far as the font menu's filter is concerned. +fn pinned_variable_font(family: &str) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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 = 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): the checkbox keeps only variable +/// families, works with the typed search (which keeps the keyboard), and stays on when the menu +/// is reopened until it is turned off. +#[test] +fn font_menu_filters_variable_fonts() { + const FAMILY: &str = "Inter Variable Menu Test"; + assert_eq!(effectcraft_engine::text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1); + let variable = effectcraft_engine::text::fonts::variable_families(); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Var", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let families = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().auto.query("character.font.option.").iter().map(|e| e.id.trim_start_matches("character.font.option.").to_string()).collect() + }; + click(&mut h, "character.font"); + type_text(&mut h, "inter"); + let shown = families(&h); + assert!(shown.iter().any(|f| f == "Inter") && shown.iter().any(|f| f == FAMILY), "unfiltered at first: {shown:?}"); + click(&mut h, "character.font.variable"); + let shown = families(&h); + assert!(shown.iter().any(|f| f == FAMILY), "{shown:?}"); + assert!(shown.iter().all(|f| variable.contains(f)), "only variable families: {shown:?}"); + // The search field keeps the keyboard and narrows the filtered list further. + type_text(&mut h, " variable menu"); + assert_eq!(families(&h), vec![FAMILY.to_string()]); + type_text(&mut h, "x"); + assert!(families(&h).is_empty()); + assert!(h.state().auto.find("character.font.empty").is_some()); + key(&mut h, Key::Escape); + // Reopened: still filtered; turning it off lists the static families again. + click(&mut h, "character.font"); + h.run_steps(2); + let shown = families(&h); + assert!(!shown.is_empty() && shown.iter().all(|f| variable.contains(f)), "the filter stays on: {shown:?}"); + click(&mut h, "character.font.variable"); + type_text(&mut h, "inter"); + assert!(families(&h).iter().any(|f| f == "Inter"), "{:?}", families(&h)); + key(&mut h, Key::Escape); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); +} diff --git a/crates/ui-egui/tests/ui_frame_step.rs b/crates/ui-egui/tests/ui_frame_step.rs new file mode 100644 index 00000000..cd3ac329 --- /dev/null +++ b/crates/ui-egui/tests/ui_frame_step.rs @@ -0,0 +1,44 @@ +//! Stepping the current time from the keyboard: Page Down / Page Up and Cmd+Right / Cmd+Left +//! Arrow move one frame, with Shift ten. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use serde_json::json; + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); +} + +#[test] +fn cmd_arrows_step_the_current_frame() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let cid = session.state.active_comp.unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(2); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + }; + assert_eq!(frame(&h), 0); + let cmd = Modifiers::COMMAND; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + key(&mut h, Key::ArrowRight, cmd); + assert_eq!(frame(&h), 1); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_eq!(frame(&h), 2); + key(&mut h, Key::ArrowRight, cmd_shift); + assert_eq!(frame(&h), 12); + key(&mut h, Key::ArrowLeft, cmd); + assert_eq!(frame(&h), 11); + key(&mut h, Key::ArrowLeft, cmd_shift); + assert_eq!(frame(&h), 1); + // The plain arrows still nudge layers, not the time. + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(frame(&h), 1); +} diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs index 5334cf07..90a5af15 100644 --- a/crates/ui-egui/tests/ui_fx_editors.rs +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -250,6 +250,36 @@ fn reshape_correspondence_points_on_the_viewer() { assert_eq!(h.state().session.history.undo.len(), undo_len + 1); } +/// #303: with Mesh Warp selected, the viewer shows its grid; dragging a vertex warps the mesh +/// (one undo step), and the other vertices stay put. +#[test] +fn mesh_warp_vertices_drag_on_the_viewer() { + let (mut s0, l, fx) = with_effect("Mesh Warp"); + let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("rows").unwrap().uid, "value": 2})).unwrap(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("columns").unwrap().uid, "value": 2})).unwrap(); + let mut h = harness(s0); + h.state_mut().toggle_maximize(PanelKind::EffectControls); + h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)]; + for _ in 0..6 { + h.step(); + } + let v = |r: usize, c: usize| format!("viewer.meshWarp.{fx}.{r}.{c}"); + let (tl, mid, br) = (rect(&h, &v(0, 0)).center(), rect(&h, &v(1, 1)).center(), rect(&h, &v(2, 2)).center()); + assert!(((tl.x + br.x) / 2.0 - mid.x).abs() < 1.0 && ((tl.y + br.y) / 2.0 - mid.y).abs() < 1.0, "{tl:?} {mid:?} {br:?}"); + assert!(br.x - tl.x > 100.0, "the grid spans the layer: {tl:?} {br:?}"); + assert_eq!(s(value(&h, l, fx, "mesh")), ""); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, mid, mid + egui::vec2(40.0, 0.0)); + let mesh = effectcraft_engine::effects::parse_mesh(&s(value(&h, l, fx, "mesh"))); + assert_eq!(mesh.len(), 9, "{mesh:?}"); + assert!(mesh[4].0 > 10.0 && mesh[4].1.abs() < 1.0, "the centre vertex moved right: {mesh:?}"); + assert!(mesh.iter().enumerate().all(|(i, o)| i == 4 || *o == (0.0, 0.0)), "{mesh:?}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1); + let moved = rect(&h, &v(1, 1)).center(); + assert!((moved.x - mid.x - 40.0).abs() < 2.0, "the handle follows the vertex: {mid:?} → {moved:?}"); +} + /// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the /// Preview panel scrolls in the default workspace (where it shows just the transport). #[test] diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs new file mode 100644 index 00000000..fa8c7b73 --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -0,0 +1,493 @@ +//! The Gradient Editor (#612): the Timeline's "Edit Gradient…" opens it on a gradient property +//! (here a shape layer's Gradient Fill ▸ Colors). OK sets the edited gradient with one undo step +//! at the composition time it opened at (offset and stretched layers too); OK with no change, Cancel +//! and Escape change nothing; on an animated gradient only the keyframe at that time changes, with +//! a warning when its stop counts no longer match a neighbour's. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::{Gradient, Value}; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::panels::gradient_editor::Track; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +const WARNING: &str = "These adjacent keyframes cannot interpolate because their stop counts differ."; + +/// The uids from the layer's root down to the Gradient Fill group, and its Colors property. +fn gradient_fill(g: &PropGroup) -> Option<(Vec, u64)> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some((vec![x.uid], x.get("colors")?.uid)); + } + if let Some((mut path, colors)) = gradient_fill(x) { + path.insert(0, x.uid); + return Some((path, colors)); + } + } + None +} + +/// A 20 s comp with a rectangle shape layer holding a Gradient Fill, its rows open in the +/// Timeline. `timing` runs on the layer first (start, stretch). Returns (harness, layer, Colors uid). +fn setup(timing: &[(&str, serde_json::Value)]) -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 20})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap(); + for (cmd, p) in timing { + let mut p = p.clone(); + p["layers"] = json!([l]); + s.execute(cmd, p).unwrap(); + } + let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.extend(groups); + h.run_steps(3); + (h, l, colors) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> [f32; 4] { + h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = rect(h, id); + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +/// The point at `f` (0..1) along the bar, at the height of automation id `row`. +fn along(h: &Harness<'_, EffectcraftApp>, row: &str, f: f32) -> Pos2 { + let bar = rect(h, "dialog.gradient.bar"); + pos2(bar[0] + bar[2] * f, center(h, row).y) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + click_at(h, p); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + let f = k as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * f)); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn colors_prop(h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64) -> effectcraft_engine::project::Property { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().clone() +} + +fn gradient(v: &Value) -> Gradient { + match v { + Value::Gradient(g) => g.clone(), + other => panic!("not a gradient: {other:?}"), + } +} + +fn open_editor(h: &mut Harness<'_, EffectcraftApp>, colors: u64) { + let id = format!("timeline.prop.{colors}.value"); + assert!(h.state().auto.find(&id).is_some(), "no automation id {id}: the Timeline's gradient value isn't clickable"); + click(h, &id); + h.run_steps(2); +} + +fn set_time(h: &mut Harness<'_, EffectcraftApp>, t: f64) { + h.state_mut().session.execute("time.set", json!({"time": t})).unwrap(); + h.run_steps(2); +} + +fn add_key(h: &mut Harness<'_, EffectcraftApp>, l: u64, colors: u64, comp_t: f64, g: &Gradient) { + h.state_mut() + .session + .execute("prop.addKey", json!({"layer": l, "prop": colors, "time": comp_t, "timeBase": "comp", "value": Value::Gradient(g.clone()).to_json()})) + .unwrap(); +} + +fn near(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) +} + +/// The shared colour picker is showing (its colour field is in the accessibility tree). +fn picker_open(h: &Harness<'_, EffectcraftApp>) -> bool { + h.query_by_label("Color field").is_some() +} + +/// Type `hex` into the open picker's Hex field, over the code it shows. +fn type_hex(h: &mut Harness<'_, EffectcraftApp>, hex: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click_at(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(hex.into())); + h.run_steps(3); +} + +fn stop_color(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f32; 4] { + h.state().gradient_draft().colors.iter().find(|s| s.id == id).expect("the stop").value +} + +/// The numbers the editor's fields show (their accessibility values). +fn shown_numbers(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.query_all_by(|n| n.numeric_value().is_some()).filter_map(|n| n.accesskit_node().numeric_value()).collect() +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +#[test] +fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + // Add a colour stop a quarter along, recolour it, and drag the right opacity stop to the middle. + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + let (track, id) = h.state().gradient_draft().selected.expect("the new stop is selected"); + assert_eq!(track, Track::Color); + let sampled = stop_color(&h, id); + // The swatch opens the shared colour picker over the modal editor. A click in its colour field + // reaches it (a modal blocks the layers below it), and so does typing a hex code. + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "the colour picker opened"); + let field = h.get_by_label("Color field").rect().center(); + click_at(&mut h, field + egui::vec2(20.0, -20.0)); + assert_ne!(stop_color(&h, id), sampled, "a click in the picker's colour field changed the stop"); + type_hex(&mut h, "336699"); + let picked = stop_color(&h, id); + assert!(near(picked, [0.2, 0.4, 0.6, sampled[3]]), "the hex code's colour, and the stop kept its alpha: {picked:?}"); + // Clicking outside the picker keeps the colour and closes it; the editor stays. + let title = h.get_by_label("Gradient Editor").rect().center(); + click_at(&mut h, title); + assert!(!picker_open(&h), "the picker closed"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + assert_eq!(stop_color(&h, id), picked); + let from = center(&h, "dialog.gradient.opacityStop.1"); + let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); + drag(&mut h, from, to); + assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + let g = gradient(&colors_prop(&h, l, colors).value); + assert_eq!(g.colors.len(), 3, "{g:?}"); + assert!((g.colors[1].0 - 0.25).abs() < 0.02 && g.colors[1].1 == picked, "{g:?}"); + assert!((g.opacities[1].0 - 0.5).abs() < 0.02, "{g:?}"); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(colors_prop(&h, l, colors).value, before, "one undo restores it"); +} + +#[test] +fn cancel_and_escape_change_nothing() { + for cancel in ["button", "escape"] { + let (mut h, _, colors) = setup(&[]); + let project = h.state().session.project.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addOpacity", 0.4); + click_at(&mut h, p); + assert_eq!(h.state().gradient_draft().opacities.len(), 3); + if cancel == "button" { + click(&mut h, "dialog.gradient.cancel"); + } else { + key(&mut h, Key::Escape); + } + assert_eq!(h.state().dialog, None, "{cancel}"); + assert_eq!(h.state().session.project, project, "{cancel} leaves the project as it was"); + assert_eq!(undo_len(&h), undo); + } +} + +/// OK without a change adds no keyframe between two (prop.set would add one). +#[test] +fn ok_without_changes_adds_no_keyframe() { + let (mut h, l, colors) = setup(&[]); + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &Gradient::default()); + set_time(&mut h, 2.0); + let undo = undo_len(&h); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).keys.len(), 2, "still the two keyframes"); + assert_eq!(undo_len(&h), undo, "no undo step"); +} + +/// On an animated gradient OK sets the keyframe at the edit time (adding one between keys) and no +/// other. +#[test] +fn animated_edits_change_only_the_keyframe_at_the_edit_time() { + let (mut h, l, colors) = setup(&[]); + let red = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], ..Gradient::default() }; + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &red); + let first = colors_prop(&h, l, colors).keys[0].clone(); + for (t, keys) in [(3.0, 2), (2.0, 3)] { + set_time(&mut h, t); + open_editor(&mut h, colors); + let d = &h.state().gradient_draft(); + let id = d.id_at(Track::Color, 1).unwrap(); + h.state_mut().gradient_draft_mut().set_rgb(id, [0.0, 0.0, 1.0]); + click(&mut h, "dialog.gradient.ok"); + let pr = colors_prop(&h, l, colors); + assert_eq!(pr.keys.len(), keys, "at {t} s"); + assert_eq!(pr.keys[0], first, "the keyframe at 1 s is untouched"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + // The edit time snapped to the nearest frame (29.97 fps), as prop.set snaps it. + let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(t)).seconds(); + let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6).expect("a keyframe at the edit time"); + assert_eq!(gradient(&k.value).colors[1].1, [0.0, 0.0, 1.0, 1.0], "at {t} s"); + } +} + +/// The key goes at the composition time the editor opened at, on a layer that starts late or is +/// stretched (layer time differs from composition time). +#[test] +fn offset_and_stretched_layers_get_the_keyframe_at_the_comp_time() { + for timing in + [vec![("layer.timing", json!({"start": 5.0}))], vec![("layer.timing", json!({"start": 5.0})), ("layer.timeStretch", json!({"percent": 200.0}))]] + { + let (mut h, l, colors) = setup(&timing); + set_time(&mut h, 5.5); + h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": l, "prop": colors})).unwrap(); + set_time(&mut h, 6.0); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + click(&mut h, "dialog.gradient.ok"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let pr = layer.props.find(colors).unwrap(); + let comp_times: Vec = pr.keys.iter().map(|k| layer.comp_time(k.time).seconds()).collect(); + assert_eq!(pr.keys.len(), 2, "{timing:?}: {comp_times:?}"); + // Composition time 6 s snapped to the nearest frame (29.97 fps: 6.006 s), as prop.set snaps + // it: the layer's start and stretch don't move it. + let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(6.0)).seconds(); + let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6); + let k = k.unwrap_or_else(|| panic!("{timing:?}: no keyframe at comp time {at} s: {comp_times:?}")); + assert_eq!(gradient(&k.value).colors.len(), 3, "{timing:?}"); + } +} + +/// Adding a stop next to keyframes with fewer stops warns that they can't interpolate; it still +/// saves, the neighbour keeps its stops, and the animation holds until the edited keyframe. +#[test] +fn mismatched_stop_counts_warn_and_neighbours_stay() { + let (mut h, l, colors) = setup(&[]); + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &Gradient::default()); + set_time(&mut h, 3.0); + open_editor(&mut h, colors); + assert!(h.query_by_label(WARNING).is_none(), "same counts: no warning"); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(h.query_by_label(WARNING).is_some(), "three colour stops next to a keyframe with two"); + click(&mut h, "dialog.gradient.ok"); + let pr = colors_prop(&h, l, colors); + assert_eq!(pr.keys.len(), 2); + assert_eq!(gradient(&pr.keys[0].value), Gradient::default(), "the neighbour keeps its stops"); + assert_eq!(gradient(&pr.keys[1].value).colors.len(), 3); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mid = pr.value_at(layer.layer_time(Tick::from_seconds_f64(2.0))); + assert_eq!(gradient(&mid), Gradient::default(), "it holds the first keyframe instead of blending"); +} + +/// Delete removes the selected stop (never a layer, and never the last stop of a track), but not +/// while typing in a field. +#[test] +fn delete_removes_the_selected_stop_but_not_the_last_or_while_typing() { + let (mut h, _, colors) = setup(&[]); + let layers = h.state().session.active_comp().unwrap().layers.len(); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.0"); + key(&mut h, Key::Delete); + assert_eq!(h.state().gradient_draft().colors.len(), 1, "Delete removed the selected stop"); + click(&mut h, "dialog.gradient.colorStop.0"); + key(&mut h, Key::Delete); + click(&mut h, "dialog.gradient.delete"); + assert_eq!(h.state().gradient_draft().colors.len(), 1, "the last colour stop stays"); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), layers, "Delete never reaches the layer"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + // Typing in Location: Delete edits the text, not the stops. + click(&mut h, "dialog.gradient.opacityStop.0"); + click(&mut h, "dialog.gradient.location"); + assert!(h.ctx.egui_wants_keyboard_input(), "the Location field takes the keyboard"); + key(&mut h, Key::Delete); + assert_eq!(h.state().gradient_draft().opacities.len(), 2, "typing doesn't delete a stop"); +} + +/// One colour stop saves (the typed form; plain JSON needs two) and one undo brings both back. +#[test] +fn a_single_colour_stop_saves_and_undoes() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.1"); + click(&mut h, "dialog.gradient.delete"); + assert_eq!(h.state().gradient_draft().colors.len(), 1); + click(&mut h, "dialog.gradient.ok"); + let g = gradient(&colors_prop(&h, l, colors).value); + assert_eq!(g.colors, gradient(&before).colors[..1].to_vec(), "{g:?}"); + assert_eq!(g.opacities, gradient(&before).opacities, "opacity stops unchanged"); + assert_eq!(undo_len(&h), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(colors_prop(&h, l, colors).value, before); +} + +/// Typing a Location that moves a stop past its neighbour keeps the selection on that stop, and +/// in the same frame the Opacity field shows its value, not the neighbour's (now at its old index). +#[test] +fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + // Opacity 100% at 0 and 40% at 1, plus a stop at 0.5 sampled from them: 70%. + let right = h.state().gradient_draft().opacities[1].id; + h.state_mut().gradient_draft_mut().set_opacity(right, 0.4); + let p = along(&h, "dialog.gradient.addOpacity", 0.5); + click_at(&mut h, p); + let middle = h.state().gradient_draft().selected.expect("the new stop").1; + let i = h.state().gradient_draft().index_of(Track::Opacity, middle).unwrap(); + assert!((h.state().gradient_draft().opacities[i].value - 0.7).abs() < 0.02, "it sampled 70%"); + // Select the 100% stop at 0 and type 80 into Location: it passes the stop at 0.5. + let first = h.state().gradient_draft().opacities[0].id; + click(&mut h, "dialog.gradient.opacityStop.0"); + click(&mut h, "dialog.gradient.location"); + assert!(h.ctx.egui_wants_keyboard_input(), "the Location field is being typed in"); + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(Event::Text("80".into())); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let d = h.state().gradient_draft(); + assert_eq!(d.selected, Some((Track::Opacity, first)), "the moved stop stays selected"); + assert_eq!(d.index_of(Track::Opacity, first), Some(1), "it is now after the stop at 0.5"); + // The frame that moved it: Opacity already shows this stop's 100%, never the neighbour's 70% + // that is now at its old index. (Location itself was drawn before the move: 0 until the next.) + let shown = shown_numbers(&h); + assert!(shown.contains(&100.0), "this stop's 100%: {shown:?}"); + assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "never the neighbour's 70%: {shown:?}"); + h.run_steps(2); + let shown = shown_numbers(&h); + assert!(shown.contains(&80.0) && shown.contains(&100.0), "Location 80 and Opacity 100%: {shown:?}"); + assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); +} + +/// A layer deleted while the editor is open: OK reports it, without a crash. +#[test] +fn a_layer_deleted_while_editing_is_reported() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); +} + +/// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that +/// isn't a gradient property, from the engine and through the UI, and opens nothing. +#[test] +fn edit_gradient_by_id_rejects_bad_targets() { + let (mut h, l, colors) = setup(&[]); + let ctx = h.ctx.clone(); + let opacity = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid; + for (p, err) in [(json!({"layer": 9999, "prop": colors}), "no layer"), (json!({"layer": l, "prop": opacity}), "not a gradient")] { + let e = h.state_mut().session.execute_checked("prop.editGradient", p.clone()).expect_err("the engine refuses it"); + assert!(e.to_string().contains(err), "{p}: {e}"); + let e = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", p.clone()).expect_err("so does the UI"); + assert!(e.contains(err), "{p}: {e}"); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "{p}"); + } + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", json!({"layer": l, "prop": colors})); + assert!(r.is_ok(), "{r:?}"); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); +} + +/// In the editor, the colour picker's Escape and Cancel go back to the stop's colour and close only +/// the picker: the editor and its other edits stay (dialogs leave Escape to an open picker). +#[test] +fn picker_escape_and_cancel_restore_the_stop_and_keep_the_editor() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let added = along(&h, "dialog.gradient.addOpacity", 0.3); + click_at(&mut h, added); + click(&mut h, "dialog.gradient.colorStop.0"); + let id = h.state().gradient_draft().selected.expect("a selected stop").1; + let original = stop_color(&h, id); + for how in ["escape", "cancel"] { + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "{how}: the picker opened"); + type_hex(&mut h, "ff8000"); + assert!(near(stop_color(&h, id), [1.0, 128.0 / 255.0, 0.0, original[3]]), "{how}: the picker previews as it goes"); + if how == "escape" { + key(&mut h, Key::Escape); + } else { + // The picker's Cancel, not the editor's. + let editor_cancel = rect(&h, "dialog.gradient.cancel"); + let cancel = + h.query_all_by_label("Cancel").map(|n| n.rect()).find(|r| (r.min.x - editor_cancel[0]).abs() > 1.0 || (r.min.y - editor_cancel[1]).abs() > 1.0); + click_at(&mut h, cancel.expect("the picker's Cancel").center()); + } + assert!(!picker_open(&h), "{how}: the picker closed"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "{how}: the editor stays open"); + assert_eq!(stop_color(&h, id), original, "{how}: the stop's colour is back"); + assert_eq!(h.state().gradient_draft().opacities.len(), 3, "{how}: the editor's other edits stay"); + } +} + +/// The Escape that closes the picker is its own: the next Escape, even in the very next pass, +/// cancels the editor (a closed picker doesn't keep the dialog from it). +#[test] +fn the_escape_after_the_picker_closes_cancels_the_editor() { + let (mut h, _, colors) = setup(&[]); + let project = h.state().session.project.clone(); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.0"); + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "the picker opened"); + let escape = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + }; + escape(&mut h); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the first Escape closed only the picker"); + escape(&mut h); + assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); + assert_eq!(h.state().session.project, project, "and changes nothing"); +} diff --git a/crates/ui-egui/tests/ui_gradient_handles.rs b/crates/ui-egui/tests/ui_gradient_handles.rs new file mode 100644 index 00000000..f5d9d273 --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_handles.rs @@ -0,0 +1,158 @@ +//! #612: a shape layer's Gradient Fill start and end points are dragged in the Composition viewer, +//! as in After Effects. With the gradient (or a property in it) selected, `viewer.gradientPoint.*` +//! handles sit on the points, in the space of the group holding the gradient; a drag sets the +//! point in one undo step and doesn't move the layer. Uses only APIs that existed before the +//! handles, so it runs (and fails) on the code without them. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::panels::viewer::comp_to_screen; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// The Gradient Fill group in shape contents `g`, through nested groups. +fn gfill(g: &PropGroup) -> Option<&PropGroup> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some(x); + } + if let Some(found) = gfill(x) { + return Some(found); + } + } + None +} + +struct Ids { + layer: u64, + gfill: u64, + start: u64, + end: u64, +} + +/// A 640×360 comp with a rectangle shape layer, a Gradient Fill added inside the rectangle's group +/// (`in_group`) or at the top of its contents, after `prep` runs on the session. +fn setup(in_group: bool, prep: impl FnOnce(&mut Session, u64, u64)) -> (Session, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let group = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().next().unwrap().uid; + let mut p = json!({"layer": l, "kind": "gfill"}); + if in_group { + p["group"] = json!(group); + } + s.execute("layer.addShapeItem", p).unwrap(); + prep(&mut s, l, group); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap(); + let g = gfill(&layer.props).expect("a Gradient Fill"); + let ids = Ids { layer: l, gfill: g.uid, start: g.get("start").unwrap().uid, end: g.get("end").unwrap().uid }; + s.state.selected_layers = vec![LayerId(l)]; + (s, ids) +} + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + h +} + +fn handle(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Option { + h.state().auto.find(&format!("viewer.gradientPoint.{uid}")).map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) +} + +fn point(h: &Harness<'_, EffectcraftApp>, ids: &Ids, uid: u64) -> [f64; 2] { + h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.find(uid).unwrap().value.as_vec2() +} + +/// Screen pixels per comp pixel. +fn zoom(h: &Harness<'_, EffectcraftApp>) -> f32 { + let a = comp_to_screen(&h.ctx, [0.0, 0.0]).unwrap(); + let b = comp_to_screen(&h.ctx, [100.0, 0.0]).unwrap(); + (b.x - a.x) / 100.0 +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn near(a: [f64; 2], b: [f64; 2], tol: f64) -> bool { + (a[0] - b[0]).abs() <= tol && (a[1] - b[1]).abs() <= tol +} + +/// With the Gradient Fill selected, its End Point is a handle on the point; dragging it moves the +/// point by the drag (in comp pixels, the layer and group being unscaled), as one undo step, +/// without moving the layer. +#[test] +fn dragging_the_end_point_moves_it_in_one_undo_step() { + let (mut s, ids) = setup(false, |_, _, _| {}); + s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)]; + let mut h = harness(s); + let at = handle(&h, ids.end).unwrap_or_else(|| panic!("no viewer.gradientPoint.{}: the Gradient Fill's End Point isn't draggable in the viewer", ids.end)); + assert!(handle(&h, ids.start).is_some(), "and its Start Point"); + let (end, start) = (point(&h, &ids, ids.end), point(&h, &ids, ids.start)); + let position = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let undo = h.state().session.history.undo.len(); + let z = zoom(&h); + drag(&mut h, at, at + vec2(60.0, 30.0) * z); + let moved = point(&h, &ids, ids.end); + assert!(near(moved, [end[0] + 60.0, end[1] + 30.0], 1.5), "End Point {end:?} moved by the drag: {moved:?}"); + assert_eq!(point(&h, &ids, ids.start), start, "the Start Point stays"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().clone(); + assert_eq!(layer.props.prop("transform/position").unwrap().value, position, "the layer didn't move"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "the drag is one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(point(&h, &ids, ids.end), end, "one undo puts it back"); +} + +/// A gradient inside a moved and scaled group: its handles sit where the group draws the points, +/// and a drag converts back into the group's space (half as far at 200%). +#[test] +fn points_follow_the_group_transform() { + let (mut s, ids) = setup(true, |s, l, group| { + let tr = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(group).unwrap().sub("transform").unwrap().clone(); + s.execute("prop.set", json!({"layer": l, "prop": tr.get("scale").unwrap().uid, "value": [200.0, 200.0]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "prop": tr.get("position").unwrap().uid, "value": [40.0, -20.0]})).unwrap(); + }); + s.state.selected_props = vec![(LayerId(ids.layer), ids.start)]; + let mut h = harness(s); + // Layer at the comp's centre (320, 180); group: position (40, -20), scale 200%. + let start = point(&h, &ids, ids.start); + let want = comp_to_screen(&h.ctx, [(320.0 + 40.0 + 2.0 * start[0]) as f32, (180.0 - 20.0 + 2.0 * start[1]) as f32]).unwrap(); + let at = handle(&h, ids.start).expect("a Start Point handle (selecting a property in the gradient shows it)"); + assert!((at - want).length() < 1.5, "the handle is where the group draws the point: {at:?} vs {want:?}"); + let z = zoom(&h); + drag(&mut h, at, at + vec2(40.0, 20.0) * z); + let moved = point(&h, &ids, ids.start); + assert!(near(moved, [start[0] + 20.0, start[1] + 10.0], 1.0), "in group space, half the comp drag: {start:?} → {moved:?}"); +} + +/// No handles unless the gradient (or a property in it) is selected, or with layer controls off. +#[test] +fn handles_show_only_for_a_selected_gradient() { + let (s, ids) = setup(false, |_, _, _| {}); + let h = harness(s); + assert!(handle(&h, ids.end).is_none(), "only the layer is selected"); + let (mut s, ids) = setup(false, |_, _, _| {}); + let position = s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop("transform/position").unwrap().uid; + s.state.selected_props = vec![(LayerId(ids.layer), position)]; + let h = harness(s); + assert!(handle(&h, ids.end).is_none(), "another property is selected"); + let (mut s, ids) = setup(false, |_, _, _| {}); + s.state.selected_props = vec![(LayerId(ids.layer), ids.gfill)]; + let mut h = harness(s); + assert!(handle(&h, ids.end).is_some()); + h.state_mut().ui.viewer.show_layer_controls = false; + h.run_steps(3); + assert!(handle(&h, ids.end).is_none(), "View ▸ Show Layer Controls off hides them"); +} diff --git a/crates/ui-egui/tests/ui_gradient_value.rs b/crates/ui-egui/tests/ui_gradient_value.rs new file mode 100644 index 00000000..84c3844c --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_value.rs @@ -0,0 +1,49 @@ +//! #612: a gradient property's "Edit Gradient…" in the Timeline opens an editor. Before the fix +//! the value only painted its label: no automation id, nothing to click. This test uses only APIs +//! that existed before the Gradient Editor, so it runs (and fails) on the code without it; the +//! editor itself is tested in `ui_gradient_editor`. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// The uids from the layer's root down to the Gradient Fill group, and its Colors property. +fn gradient_fill(g: &PropGroup) -> Option<(Vec, u64)> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some((vec![x.uid], x.get("colors")?.uid)); + } + if let Some((mut path, colors)) = gradient_fill(x) { + path.insert(0, x.uid); + return Some((path, colors)); + } + } + None +} + +#[test] +fn the_timeline_gradient_value_opens_an_editor() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap(); + let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.extend(groups); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let id = format!("timeline.prop.{colors}.value"); + let r = h.state().auto.find(&id).unwrap_or_else(|| panic!("no automation id {id}: the Timeline's Edit Gradient… isn't clickable")).rect; + let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(h.state().dialog.is_some(), "clicking Edit Gradient… opens an editor"); +} diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 4119d79b..dd20e55b 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -72,6 +72,30 @@ fn project_rename_replaces_the_name() { assert!(!h.ctx.egui_wants_keyboard_input(), "Enter releases the keyboard"); } +/// #881: Tab in the Project panel's rename field commits the name like Enter and gives the keyboard +/// back to the panel; it doesn't leave keyboard focus on another widget (the row's label swatch), +/// where Enter would no longer rename and the arrow keys would move between widgets. +#[test] +fn tab_in_the_project_rename_field_commits_and_keeps_enter_renaming() { + let (mut h, comp) = harness(); + let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone(); + let edit = format!("project.item.{comp}.nameEdit"); + let editing = |h: &Harness<'_, EffectcraftApp>| h.state().auto.elements.iter().any(|e| e.id == edit); + let p = center(&h, &format!("project.item.{comp}.name")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + type_text(&mut h, "Hero"); + key(&mut h, Key::Tab); + assert_eq!(name(&h), "Hero", "Tab commits the name"); + assert!(!editing(&h), "Tab closes the rename field"); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "no other widget takes the keyboard"); + key(&mut h, Key::Enter); + assert!(editing(&h), "Enter renames again"); + type_text(&mut h, "Main"); + key(&mut h, Key::Enter); + assert_eq!(name(&h), "Main"); +} + #[test] fn timecode_field_replaces_commits_and_cancels() { let (mut h, _) = harness(); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index a7d1d1ae..74352824 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -398,3 +398,40 @@ fn properties_panel_edits_every_selected_layer() { assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}"); } } + +/// #770: Proportional Scrubbing (After Effects 26.2). With three layers selected, a Ctrl+Alt +/// (Cmd+Opt) scrub of Position X in the Timeline leaves the first selected layer where it is, +/// moves the last by the whole change and the middle one by half, whichever layer is scrubbed. +#[test] +fn ctrl_alt_scrub_in_the_timeline_is_proportional() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for c in ["#e04020", "#20e040", "#2040e0"] { + let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [100.0, 90.0]})).unwrap(); + ids.push(l); + } + // Selection order: the first created, then the second, then the third. + for (i, l) in ids.iter().enumerate() { + s.execute("layer.select", json!({"layers": [l], "add": i > 0})).unwrap(); + } + let middle = ids[1]; + let uid = s.active_comp().unwrap().layer(LayerId(middle)).unwrap().props.prop("transform/position").unwrap().uid; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.position", json!({})).unwrap(); + h.run_steps(4); + let x = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().value.components()[0] + }; + let e = h.state().auto.find(&format!("timeline.prop.{uid}.value.0")).cloned().expect("the middle layer's Position X shows"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + let ctrl_alt = egui::Modifiers { alt: true, ctrl: true, command: true, ..Default::default() }; + drag(&mut h, from, from + egui::vec2(40.0, 0.0), ctrl_alt); + let moved: Vec = ids.iter().map(|l| x(&h, *l) - 100.0).collect(); + assert!(moved[2] > 1.0, "the last selected layer moved: {moved:?}"); + assert!(moved[0].abs() < 1e-9, "the first selected layer stays: {moved:?}"); + assert!((moved[1] - moved[2] / 2.0).abs() < 1e-6, "the middle one moves by half: {moved:?}"); +} diff --git a/crates/ui-egui/tests/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/ui_layer_panel_zoom.rs new file mode 100644 index 00000000..5fa2b1d9 --- /dev/null +++ b/crates/ui-egui/tests/ui_layer_panel_zoom.rs @@ -0,0 +1,177 @@ +//! #753: the Layer panel zooms and pans (magnification menu, View ▸ Zoom In / Zoom Out while it +//! is focused, the wheel, the Zoom and Hand tools), and the Roto Brush explains itself in the +//! Composition panel (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::state::Tool; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn app() -> EffectcraftApp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + EffectcraftApp::new(s) +} + +fn box_id(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0 +} + +/// The harness with Box open in the Layer panel (focused). +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.run_steps(3); + let id = box_id(&h); + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().ui.focused, PanelKind::Layer); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: egui::PointerButton, pressed: bool) { + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + press(h, p, egui::PointerButton::Primary, true); + h.step(); + press(h, p, egui::PointerButton::Primary, false); + h.run_steps(2); +} + +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + press(h, from, button, true); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + press(h, to, button, false); + h.run_steps(2); +} + +fn command(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap(); + h.run_steps(3); +} + +/// Shown magnification: the canvas width per layer pixel (Box is 80 px wide). +fn shown(h: &Harness<'_, EffectcraftApp>) -> f32 { + rect(h, "layerPanel.canvas").width() / 80.0 +} + +#[test] +fn zoom_commands_act_on_the_focused_layer_panel() { + let mut h = harness(); + assert!(h.state().auto.find("layerPanel.magnification").is_some(), "the Layer panel has a magnification menu"); + let fit = shown(&h); + let comp_zoom = h.state().ui.viewer.zoom; + command(&mut h, "view.zoomOut"); + let z = h.state().ui.layer_zoom.expect("Zoom Out sets the Layer panel's magnification"); + assert!(z < fit, "{z} < {fit}"); + assert!((shown(&h) - z).abs() < 0.01, "the canvas is drawn at the new magnification"); + assert_eq!(h.state().ui.viewer.zoom, comp_zoom, "the Composition viewer keeps its magnification"); + command(&mut h, "view.zoomIn"); + assert!(h.state().ui.layer_zoom.unwrap() > z); + command(&mut h, "view.fit"); + assert_eq!(h.state().ui.layer_zoom, None); + assert!((shown(&h) - fit).abs() < 0.01); + // With the Composition panel focused, the same commands zoom it instead. + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(2); + command(&mut h, "view.zoomIn"); + assert!(h.state().ui.viewer.zoom.is_some()); + assert_eq!(h.state().ui.layer_zoom, None); +} + +#[test] +fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + // A point a quarter of the way into the layer stays under the pointer. + let at = canvas.min + canvas.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 120.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + let after = rect(&h, "layerPanel.canvas"); + assert!(after.width() > canvas.width(), "the wheel zooms in: {} → {}", canvas.width(), after.width()); + let still = after.min + after.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Hand tool drag pans; no paint or Roto stroke is made. + h.state_mut().ui.tool = Tool::Hand; + h.run_steps(2); + let before = rect(&h, "layerPanel.canvas").min; + let c = rect(&h, "layerPanel.area").center(); + drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(60.0, 30.0)); + let moved = rect(&h, "layerPanel.canvas").min - before; + assert!((moved.x - 60.0).abs() < 1.0 && (moved.y - 30.0).abs() < 1.0, "{moved:?}"); + // Middle-drag pans with a Roto Brush tool, painting nothing. + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(2); + let before = rect(&h, "layerPanel.canvas").min; + drag_with(&mut h, egui::PointerButton::Middle, c, c - vec2(40.0, 20.0)); + let moved = rect(&h, "layerPanel.canvas").min - before; + assert!((moved.x + 40.0).abs() < 1.0 && (moved.y + 20.0).abs() < 1.0, "{moved:?}"); + let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(box_id(&h))).unwrap().clone(); + assert!(layer.effects().is_none_or(|fx| fx.groups().count() == 0), "panning adds no Roto Brush effect"); +} + +#[test] +fn zoom_tool_click_zooms_in_the_layer_panel() { + let mut h = harness(); + let fit = shown(&h); + h.state_mut().ui.tool = Tool::Zoom; + h.run_steps(2); + let c = rect(&h, "layerPanel.canvas").center(); + click(&mut h, c); + let z = h.state().ui.layer_zoom.expect("the Zoom tool sets the Layer panel's magnification"); + assert!((z - fit * 2.0).abs() < 0.01, "{fit} → {z}"); + assert_eq!(h.state().ui.viewer.zoom, None, "the Composition viewer keeps Fit"); +} + +#[test] +fn roto_brush_in_the_composition_panel_says_where_it_works() { + let mut h = harness(); + h.state_mut().show_panel(PanelKind::Composition); + h.state_mut().ui.layer_panel = None; + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let c = comp.center(); + click(&mut h, c); + assert!(h.state().ui.status.contains("Layer panel"), "a hint, not silence: {:?}", h.state().ui.status); + // Double-clicking the layer opens it in the Layer panel. + h.run_steps(30); + h.input_mut().events.push(Event::PointerMoved(c)); + h.step(); + // Both clicks inside egui's double-click time; the second release (its own frame, as with a + // real mouse) opens the layer after the press has focused the Composition panel. + press(&mut h, c, egui::PointerButton::Primary, true); + press(&mut h, c, egui::PointerButton::Primary, false); + press(&mut h, c, egui::PointerButton::Primary, true); + h.step(); + press(&mut h, c, egui::PointerButton::Primary, false); + h.run_steps(3); + assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); + assert_eq!(h.state().ui.focused, PanelKind::Layer); +} diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index 142a4fa3..e198a619 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -204,3 +204,24 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { assert_eq!(s.render(cid, Tick::ZERO, RenderOpts::default()).data, expected.data); std::fs::remove_dir_all(dir).unwrap(); } + +/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and +/// Avid), which the Media Browser lists and the importer reads. +#[test] +fn import_dialog_offers_mxf() { + use std::cell::RefCell; + use std::rc::Rc; + + let mut app = EffectcraftApp::new(Session::default()); + let offered = Rc::new(RefCell::new(Vec::::new())); + let seen = offered.clone(); + app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); + Vec::new() + })); + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); + assert!(result.is_null(), "cancelled: nothing imported"); + let offered = offered.borrow(); + assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); + assert!(offered.iter().any(|e| e == "mov")); +} diff --git a/crates/ui-egui/tests/ui_panel_windows.rs b/crates/ui-egui/tests/ui_panel_windows.rs new file mode 100644 index 00000000..60ae3f9c --- /dev/null +++ b/crates/ui-egui/tests/ui_panel_windows.rs @@ -0,0 +1,87 @@ +//! Multiple monitors (#486): a panel moves out of the main window into a native window of its +//! own (Move Panel to New Window), which can be placed on another screen, and back. +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::{PanelKind, PanelWindow}; +use egui::vec2; +use egui_kittest::Harness; +use serde_json::json; + +fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, params: serde_json::Value) -> Result { + effectcraft_ui_egui::menus::invoke(app, ctx, "window.panelWindow", params) +} + +#[test] +fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() { + let mut app = EffectcraftApp::new(Session::default()); + let ctx = egui::Context::default(); + assert!(app.ui.dock.contains(PanelKind::Project)); + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); + // Undocked first, then given a window of its own. + assert!(!app.ui.dock.contains(PanelKind::Project)); + assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project])); + assert!(app.in_panel_window(PanelKind::Project)); + let window = app.ui.panel_windows.iter().find(|w| w.panel == PanelKind::Project).copied().unwrap(); + assert_eq!(window.rect, PanelWindow::sanitized(window.rect)); + // Asking again changes nothing; no `window` toggles. + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); + assert_eq!(app.ui.panel_windows.len(), 1); + assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false})); + // Back in the main window, still floating where it was. + assert!(!app.in_panel_window(PanelKind::Project)); + assert!(app.ui.panel_windows.is_empty()); + assert!(app.ui.floating.iter().any(|group| group.panels == [PanelKind::Project])); + // The serialized panel form works too. + assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true})); + // Bad requests are errors and change nothing. + let before = app.ui.panel_windows.clone(); + for params in [json!({}), json!({"panel": "Nope"}), json!({"panel": 7}), json!({"panel": "Project", "window": "yes"}), json!({"panel": {"Viewer": 9}})] { + assert!(invoke(&mut app, &ctx, params.clone()).is_err(), "{params} must be rejected"); + } + assert_eq!(app.ui.panel_windows, before); +} + +#[test] +fn a_panel_in_its_own_window_leaves_the_main_window() { + let mut app = EffectcraftApp::new(Session::default()); + app.ui.start_screen = false; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + h.run_steps(3); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, json!({"panel": "Project", "window": true})).unwrap(); + h.run_steps(3); + // No in-window floating group is drawn for it (no move strip, no floating area)... + assert!(!h.state().auto.elements.iter().any(|e| e.id.starts_with("panel.float."))); + let area = egui::Id::new(("floating-panel", PanelKind::Project.id())); + assert!(!h.ctx.memory(|m| m.layer_ids().any(|layer| layer.id == area && m.areas().is_visible(&layer)))); + // ...the window draws it (here embedded: the test harness has no native windows). + assert!(h.state().auto.elements.iter().any(|e| e.id == "panel.Project")); + // Docking it back into the main window closes the window entry. + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.dockPanel", json!({"panel": "Project", "anchor": "Timeline"})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.panel_windows.is_empty()); + assert!(!h.state().in_panel_window(PanelKind::Project)); +} + +#[test] +fn panel_windows_survive_restart_with_hostile_places_made_safe() { + use effectcraft_engine::config::MemoryConfig; + use std::sync::Arc; + let store = Arc::new(MemoryConfig::default()); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + let ctx = egui::Context::default(); + invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(); + let hostile = [1.0e9, -3.0e12, 1.0e9, 0.5]; + if let Some(w) = app.ui.panel_windows.first_mut() { + w.rect = hostile; + } + // A window for a panel that doesn't float is dropped on load. + app.ui.panel_windows.push(PanelWindow { panel: PanelKind::Timeline, rect: [10.0, 10.0, 300.0, 200.0] }); + eframe::App::on_exit(&mut app); + let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() }); + assert_eq!(restarted.ui.panel_windows, vec![PanelWindow { panel: PanelKind::Project, rect: PanelWindow::sanitized(hostile) }]); + let [x, y, w, h] = PanelWindow::sanitized(hostile); + assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h)); + assert!(PanelWindow::sanitized([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite())); +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index a71bebfe..e3fee868 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -2,7 +2,9 @@ //! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress //! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown. -use effectcraft_engine::Session; +use std::sync::Arc; + +use effectcraft_engine::{Session, config::MemoryConfig}; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use egui::{Event, Pos2, pos2}; @@ -237,46 +239,147 @@ fn leaving_the_learn_workspace_closes_its_tutorials() { } } -/// #284: Settings ▸ Appearance ▸ UI Scale sizes the whole interface as soon as it is chosen, -/// Cancel puts it back, OK keeps it; Ctrl+0 (Window ▸ Project) doesn't reset it. +fn open_settings(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap(); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } +} + +/// The UI Scale setting and the zoom it gives. +fn scale(h: &Harness<'_, EffectcraftApp>) -> (u32, f32) { + (h.state().session.prefs.appearance.ui_scale, h.ctx.zoom_factor()) +} + +/// Types `text` into Settings' UI Scale a key at a time: nothing applies (and the field keeps +/// the keyboard) until Enter. +fn type_scale(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + let before = scale(h); + click(h, "settings.appearance.uiScale"); + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(2); + assert_eq!(scale(h), before, "{text}: not while typing"); + assert!(h.ctx.memory(|m| m.focused().is_some()), "{text}: the field keeps the keyboard"); + } + key(h, egui::Key::Enter, egui::Modifiers::NONE); + // (Settings re-centres a frame after the zoom changes.) + h.run_steps(4); +} + +/// #284, #682: Settings ▸ Appearance ▸ UI Scale takes any typed percentage from 75 to 200 and +/// sizes the whole interface once it is entered, Cancel puts it back, OK keeps it and later +/// sessions start with it; Ctrl+0 (Window ▸ Project) doesn't reset it. #[test] fn ui_scale_setting_sizes_the_interface() { let mut h = harness(); let ctx = h.ctx.clone(); - let choose = |h: &mut Harness<'_, EffectcraftApp>, label: &str| { - let ctx = h.ctx.clone(); - effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap(); - h.run_steps(3); - click(h, "settings.appearance.uiScale"); - let option = h.query_by_label(label).unwrap_or_else(|| panic!("the {label} choice")).rect().center(); - click_at(h, option); - h.run_steps(2); - }; assert_eq!(ctx.zoom_factor(), 1.0); - choose(&mut h, "150%"); - assert_eq!(h.state().session.prefs.appearance.ui_scale, 150); - assert_eq!(ctx.zoom_factor(), 1.5, "live"); + open_settings(&mut h); + type_scale(&mut h, "85"); + assert_eq!(scale(&h), (85, 0.85), "live"); click(&mut h, "settings.cancel"); h.run_steps(2); - assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (100, 1.0), "Cancel put it back"); - choose(&mut h, "125%"); + assert_eq!(scale(&h), (100, 1.0), "Cancel put it back"); + open_settings(&mut h); + type_scale(&mut h, "300"); + assert_eq!(scale(&h), (200, 2.0), "at most 200"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0), "Cancel put it back from 200 %"); + open_settings(&mut h); + type_scale(&mut h, "50"); + assert_eq!(scale(&h), (75, 0.75), "at least 75"); + type_scale(&mut h, "90"); click(&mut h, "settings.ok"); h.run_steps(2); assert_eq!(h.state().dialog, None); - assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (125, 1.25), "OK kept it"); + assert_eq!(scale(&h), (90, 0.9), "OK kept it"); // Ctrl+0 opens the Project panel; egui's own Ctrl+0 zoom reset stays out of it. - for pressed in [true, false] { - h.input_mut().events.push(Event::Key { key: egui::Key::Num0, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }); - h.step(); - } + key(&mut h, egui::Key::Num0, egui::Modifiers::COMMAND); h.run_steps(2); - assert_eq!(ctx.zoom_factor(), 1.25); + assert_eq!(ctx.zoom_factor(), 0.9); // A saved scale applies from the first frame. - let mut s = Session::default(); - s.prefs.set("appearance.uiScale", json!(175)).unwrap(); + let s = Session { prefs: effectcraft_engine::prefs::Prefs::from_json(&h.state().session.prefs.to_json()), ..Default::default() }; let mut h2 = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h2.run_steps(3); - assert_eq!(h2.ctx.zoom_factor(), 1.75); + assert_eq!(h2.ctx.zoom_factor(), 0.9); +} + +/// Settings' Previous / Next / Cancel / OK stay whole and in view at 200 % in a 1600×1000 +/// window (they scrolled out of the dialog with its pages): the page scrolls under them, Cancel +/// restores the settings and OK saves them. +#[test] +fn settings_buttons_stay_in_view_at_200_percent() { + let store = Arc::new(MemoryConfig::default()); + let saved = || { + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + s.prefs.disk.disk_cache_enabled = false; + s.load_settings(); + s.prefs.appearance.ui_scale + }; + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + s.prefs.disk.disk_cache_enabled = false; + s.prefs.set("appearance.uiScale", json!(200)).unwrap(); + s.save_prefs(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert_eq!(scale(&h), (200, 2.0)); + let rect = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + // Whole: inside the window, and painted with none of them clipped away. + let buttons = |h: &Harness<'_, EffectcraftApp>| { + ["previous", "next", "cancel", "ok"].map(|b| { + let r = rect(h, &format!("settings.{b}")); + let clip = h + .output() + .shapes + .iter() + .find(|c| matches!(&c.shape, egui::Shape::Rect(s) if (s.rect.center() - r.center()).length() < 0.5)) + .map(|c| c.clip_rect); + assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{b} is cut off: {r:?} in {clip:?}"); + r + }) + }; + let scroll_page = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect(h, "settings.appearance.appearanceMode").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -1000.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(5); + }; + open_settings(&mut h); + let footer = buttons(&h); + let field = rect(&h, "settings.appearance.uiScale"); + scroll_page(&mut h); + assert_eq!(buttons(&h), footer, "the buttons don't scroll with the page"); + let scrolled = rect(&h, "settings.appearance.uiScale"); + assert!(scrolled.top() < field.top() && scrolled.bottom() < footer[2].top(), "the page scrolled to UI Scale: {field:?} -> {scrolled:?}"); + for (button, kept) in [("settings.cancel", 200), ("settings.ok", 150)] { + if h.state().dialog.is_none() { + open_settings(&mut h); + scroll_page(&mut h); + } + type_scale(&mut h, "150"); + assert_eq!(scale(&h), (150, 1.5)); + buttons(&h); + click(&mut h, button); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "{button} closed Settings"); + assert_eq!((scale(&h), saved()), ((kept, kept as f32 / 100.0), kept), "{button}"); + } } /// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with @@ -362,3 +465,31 @@ fn essential_graphics_scale_keeps_constrain_proportions() { let unlinked = scale(&h); assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); } + +/// #757: Effects & Presets lists the built-in Transitions presets under * Animation Presets; +/// double-clicking one applies it to the selected layer (effect plus keyframes). +#[test] +fn transition_presets_apply_from_effects_and_presets() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + open(&mut h, "effectsPresets"); + h.state_mut().ui.effects_search = "clock reveal".into(); + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets/Transitions").is_some(), "the Transitions folder is listed"); + let e = h.state().auto.find("effects.preset.builtin.clockReveal").expect("Clock Reveal is listed").clone(); + let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + // Both clicks in one frame: the harness' frame time is longer than a double-click's. + for _ in 0..2 { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + h.run_steps(3); + let comp = h.state().session.active_comp().unwrap(); + let fx = comp.layers[0].effects().unwrap().groups().next().expect("an effect was added").clone(); + assert_eq!(fx.name, "Radial Wipe"); + assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); +} diff --git a/crates/ui-egui/tests/ui_project_keyboard.rs b/crates/ui-egui/tests/ui_project_keyboard.rs new file mode 100644 index 00000000..af2a796f --- /dev/null +++ b/crates/ui-egui/tests/ui_project_keyboard.rs @@ -0,0 +1,224 @@ +//! Project panel keyboard navigation and accessibility (#776): the arrow keys move and extend the +//! selection and open / close folders, as in After Effects; the rows, sort headers and row buttons +//! reach screen readers with names, roles and states; a row focused with Tab keeps the arrows, and +//! keyboard focus follows the selection. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemId, ItemKind}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +/// A closed folder holding a comp, and a solid at the project root: (harness, folder, comp, solid). +fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let comp = ItemId(s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap(); + let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.move", json!({"items": [comp.0], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [solid.0], "folder": null})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, folder, comp, solid) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +/// Press and release `key` with `modifiers` (one frame each), then let the UI settle. +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(2); +} + +/// The item rows on screen, top to bottom. +fn rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let mut rows: Vec<(f32, ItemId)> = h + .state() + .auto + .query("project.item.") + .into_iter() + .filter_map(|e| e.id.strip_prefix("project.item.").and_then(|n| n.parse().ok()).map(|n| (e.rect[1], ItemId(n)))) + .collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + rows.into_iter().map(|(_, id)| id).collect() +} + +fn selection(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.project_selection.clone() +} + +#[test] +fn arrow_keys_move_and_extend_the_project_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + assert!(rows.len() >= 2, "rows: {rows:?}"); + let (first, second) = (rows[0], rows[1]); + click(&mut h, &format!("project.item.{}.name", first.0)); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert_eq!(selection(&h), vec![first]); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![second], "Down Arrow selects the next item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "Up Arrow selects the previous item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "the first item stays selected at the top"); + for _ in 0..rows.len() + 1 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_eq!(selection(&h), rows, "Shift+Down Arrow extends the selection to the last item"); + key(&mut h, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(selection(&h), rows[..rows.len() - 1], "Shift+Up Arrow shrinks it back toward the anchor"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]], "a plain arrow selects one item again"); + // With nothing selected, Down selects the first item and Up the last. + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![first]); + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]]); +} + +#[test] +fn right_and_left_arrows_open_and_close_the_selected_folder() { + let (mut h, folder, comp, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + assert!(!rows(&h).contains(&comp), "the folder starts closed"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.project_open_folders.contains(&folder.0), "Right Arrow opens the selected folder"); + assert!(rows(&h).contains(&comp)); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![comp], "Down Arrow walks into the open folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder], "Left Arrow on an item in a folder selects the folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.project_open_folders.contains(&folder.0), "Left Arrow closes the selected folder"); + assert_eq!(selection(&h), vec![folder]); +} + +#[test] +fn arrows_leave_the_project_selection_alone_when_another_panel_has_focus() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + h.state_mut().ui.focused = PanelKind::Timeline; + key(&mut h, Key::ArrowDown, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder]); + assert!(!h.state().ui.project_open_folders.contains(&folder.0)); +} + +#[test] +fn rows_and_headers_have_accessible_names_roles_and_states() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + let row = h.get_by_label("Folder, Folder"); + let node = row.accesskit_node(); + assert_eq!(node.role(), Role::TreeItem); + assert_eq!(node.is_selected(), Some(true), "the selected row says so"); + assert_eq!(node.data().is_expanded(), Some(false), "a closed folder says so"); + assert_eq!(node.data().level(), Some(1)); + let solid = h.get_by_label("Solid, Solid"); + assert_eq!(solid.accesskit_node().is_selected(), Some(false)); + assert_eq!(solid.accesskit_node().data().is_expanded(), None, "only folders open"); + assert_eq!(h.get_by_label("Sort by Name, ascending").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Open folder Folder").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Delete selected project items").accesskit_node().role(), Role::Button); + assert!(h.query_by_label_contains("Label of Folder: ").is_some(), "the label swatch is named"); + assert!(h.query_by_label("Create a new Folder").is_some()); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_label("Folder, Folder").accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(h.get_by_label("Main, Composition").accesskit_node().data().level(), Some(2), "the comp sits one level down"); +} + +#[test] +fn a_focused_row_keeps_the_arrows_and_focus_follows_the_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + let names: Vec = rows + .iter() + .map(|id| { + let it = h.state().session.project.item(*id).unwrap(); + format!("{}, {}", it.name, it.type_name()) + }) + .collect(); + // Focus the first row the way a screen reader (or Tab) does, without a click: the panel + // takes focus with it. + h.state_mut().ui.focused = PanelKind::Timeline; + h.get_by_label(&names[0]).focus(); + h.run_steps(3); + assert!(h.get_by_label(&names[0]).is_focused()); + assert_eq!(h.state().ui.focused, PanelKind::Project, "keyboard focus on a row focuses the panel"); + // A plain arrow moved egui's focus to the widget below; here it moves the selection and the + // focus with it. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]], "nothing was selected: Down selects the first item"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[1]]); + assert!(h.get_by_label(&names[1]).is_focused(), "keyboard focus follows the selection"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]]); + assert!(h.get_by_label(&names[0]).is_focused()); +} + +/// A mouse click on a row gives no keyboard focus to the row, so the app's shortcuts run as +/// before the arrow keys came: Delete / Backspace delete, Cmd+D duplicates, Enter renames, also +/// after an arrow key moved the selection. +#[test] +fn shortcuts_still_run_after_a_mouse_click_and_the_arrows() { + let focus = |h: &Harness<'_, EffectcraftApp>| h.ctx.memory(|m| m.focused()); + for delete_key in [Key::Delete, Key::Backspace] { + let (mut h, folder, _, _) = harness(); + let empty = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty"})).unwrap()["item"].as_u64().unwrap()); + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", empty.0)); + assert_eq!(selection(&h), vec![empty]); + assert_eq!(focus(&h), None, "a click gives the row no keyboard focus"); + key(&mut h, delete_key, Modifiers::NONE); + assert!(h.state().session.project.item(empty).is_none(), "{delete_key:?} deletes the clicked item"); + // Click, an arrow, then the shortcut. + let empty2 = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty 2"})).unwrap()["item"].as_u64().unwrap()); + h.run_steps(2); + let rows = rows(&h); + let at = rows.iter().position(|r| *r == empty2).unwrap(); + let from = if at > 0 { rows[at - 1] } else { rows[at + 1] }; + click(&mut h, &format!("project.item.{}.name", from.0)); + key(&mut h, if at > 0 { Key::ArrowDown } else { Key::ArrowUp }, Modifiers::NONE); + assert_eq!(selection(&h), vec![empty2]); + assert_eq!(focus(&h), None, "the arrows don't take keyboard focus after a click"); + key(&mut h, delete_key, Modifiers::NONE); + assert!(h.state().session.project.item(empty2).is_none(), "{delete_key:?} deletes the item the arrow moved to"); + assert!(h.state().session.project.item(folder).is_some()); + } + let (mut h, folder, _, solid) = harness(); + // Folders can't be duplicated (engine rule): duplicate the solid. + click(&mut h, &format!("project.item.{}.name", solid.0)); + let n = h.state().session.project.items.len(); + key(&mut h, Key::D, Modifiers::COMMAND); + assert_eq!(h.state().session.project.items.len(), n + 1, "Cmd+D duplicates the clicked item"); + click(&mut h, &format!("project.item.{}.name", folder.0)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().auto.find(&format!("project.item.{}.nameEdit", folder.0)).is_some(), "Enter renames the clicked item"); +} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 4c255cad..7d0ebf66 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -127,16 +127,21 @@ fn footage_context_menu_interprets_the_footage() { } h.run_steps(2); }; + // The menu's Interpret Footage entry, not the panel's footer button of the same name. + let menu_entry = |h: &Harness<'_, EffectcraftApp>| { + let footer = center(h, "project.interpret"); + h.query_all_by_label_contains("Interpret Footage").find(|n| !n.rect().contains(footer)).map(|n| n.rect()) + }; let at = center(&h, &format!("project.item.{}.name", solid.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![solid]); - assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage"); + assert!(menu_entry(&h).is_none(), "a solid isn't footage"); h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); let at = center(&h, &format!("project.item.{}.name", clip.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item"); - let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect(); + let sub = menu_entry(&h).expect("Interpret Footage in the menu"); h.input_mut().events.push(Event::PointerMoved(sub.center())); h.run_steps(3); let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect(); diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 5459be8c..930c43eb 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -136,8 +136,11 @@ fn cached_frames_are_counted_at_one_third_resolution() { let mut s = Session::default(); let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + // Each composition keeps its own Resolution (#417). + if let Some(comp) = std::sync::Arc::make_mut(&mut s.project).comp_mut(ItemId(c)) { + comp.resolution = res; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); - h.state_mut().ui.viewer.res = res; settle(&mut h); let series = h.state().shown_series(ItemId(c)); assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}"); diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index 5a01d18f..6cfc2db7 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -224,3 +224,50 @@ fn on_draw_concave_fills_and_images_render() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + +/// ScriptUI mouse events (#766): the pointer over a script window reaches the script's +/// `addEventListener` listeners (element-relative position, button, click count), and a +/// pointer click on a checkbox with a click listener toggles it once. +#[test] +fn script_windows_send_mouse_events_to_listeners() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Pad"); + var pad = w.add("group", undefined, undefined, { name: "pad" }); + pad.preferredSize = [120, 80]; + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [400, 20]; + var snap = w.add("checkbox", undefined, "Snap", { name: "snap" }); + var seen = []; + var rec = function (e) { seen.push(e.type + " " + Math.round(e.clientX) + "," + Math.round(e.clientY) + " b" + e.button + " d" + e.detail); log.text = seen.join("|"); }; + pad.addEventListener("mouseover", rec); + pad.addEventListener("mousedown", rec); + pad.addEventListener("mouseup", rec); + pad.addEventListener("click", rec); + snap.addEventListener("click", function (e) { log.text = "snap " + snap.value + " d" + e.detail; }); + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "pad.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + let text = |h: &Harness<'_, EffectcraftApp>, name: &str| h.state().session.script_ui.windows[0].root.lookup(&json!(name)).cloned().unwrap(); + let pad = rect(&h, &format!("scriptui.{win}.pad")); + let at = pad.min + vec2(10.0, 20.0); + click_at(&mut h, at); + assert_eq!(text(&h, "log").text, "mouseover 10,20 b0 d0|mousedown 10,20 b0 d1|mouseup 10,20 b0 d1|click 10,20 b0 d1"); + click(&mut h, &format!("scriptui.{win}.snap")); + let (snap, log) = (text(&h, "snap"), text(&h, "log")); + assert!(snap.checked, "toggled once"); + assert_eq!(log.text, "snap true d1"); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(c)); + h.run_steps(1); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); +} diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/ui_settings_info.rs index 64f088fe..9d87eb04 100644 --- a/crates/ui-egui/tests/ui_settings_info.rs +++ b/crates/ui-egui/tests/ui_settings_info.rs @@ -47,11 +47,15 @@ fn check_info_transaction(via_escape: bool) { let edited_brightness = h.state().session.prefs.appearance.brightness; assert!((edited_brightness - original_brightness).abs() > 0.01, "real brightness slider changed"); assert_eq!(saved(&store).prefs.appearance.brightness, original_brightness, "unconfirmed edit is not saved"); - // Use the upstream percent-valued Scale choice; keyboard zoom is deliberately disabled. + // Type a UI Scale; keyboard zoom is deliberately disabled. let at = rect(&h, "settings.appearance.uiScale").center(); click(&mut h, at); - let at = h.get_by_label("125%").rect().center(); - click(&mut h, at); + h.event(Event::Text("125".into())); + for pressed in [true, false] { + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); assert_eq!(h.state().session.prefs.appearance.ui_scale, 125); assert_eq!(saved(&store).prefs.appearance.ui_scale, 100, "unconfirmed scale edit is not saved"); // Let UI Scale settle the centered dialog before reading its next hit target. @@ -111,3 +115,42 @@ fn gpu_information_ok_returns_to_settings_and_cancel_restores_preferences() { fn gpu_information_escape_returns_to_settings_and_ok_saves_preferences() { check_info_transaction(true); } + +/// GPU Information names the adapter the window got and says why the compositor is off, so a +/// card that the compositor could not use does not look undetected (#596). +#[test] +fn gpu_information_names_the_adapter_and_why_the_gpu_is_off() { + let session = Session { + accel_note: Some("GPU compositor unavailable: no compute shaders".into()), + window_adapter: Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into()), + ..Default::default() + }; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.gpuInfo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Info)); + assert!(h.query_by_label_contains("Window graphics: NVIDIA GeForce RTX 4070").is_some(), "the window's adapter is shown"); + assert!(h.query_by_label_contains("GPU compositing is off: GPU compositor unavailable").is_some(), "the reason is shown"); +} + +#[test] +fn gpu_information_dialog_stays_in_place() { + let mut session = Session::default(); + session.prefs.appearance.ui_scale = 125; + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1280.0, 720.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"previews"})).unwrap(); + h.run_steps(3); + let button = rect(&h, "settings.button.GPU Information").center(); + click(&mut h, button); + let start = h.get_by_label("GPU Information").rect(); + for _ in 0..20 { + h.step(); + let current = h.get_by_label("GPU Information").rect(); + assert!((current.min - start.min).length() < 1.0, "GPU Information dialog moved"); + } +} diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 4d6777b6..7f4689bf 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -167,6 +167,18 @@ fn nested_comp_markers_show_on_the_precomp_bar() { assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds()); } +/// #637: double-clicking a precomp layer's bar opens its comp, as double-clicking its name does. +#[test] +fn double_clicking_a_precomp_bar_opens_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.run_steps(4); + let l = h.state().session.project.comp(ItemId(main)).unwrap().layers[0].id.0; + let bar = rect(&h, &format!("timeline.layer.{l}.bar")); + click_n(&mut h, bar.center(), 2); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre))); +} + /// Issue #46: a panel dragged by its panel-menu (hamburger) button onto the left edge of another /// group docks there as its own group; dragging the gap between panels resizes them. #[test] diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 1c2c7355..d3be6a44 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -160,6 +160,137 @@ fn type_tool_click_type_edit_and_commit() { assert!(h.state().session.state.text_edit.is_none()); } +/// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the +/// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: +/// typed characters change no text and Space plays. The Character panel keeps the edit, and a +/// text layer clicked into being and left empty goes away. +#[test] +fn clicking_another_panel_stops_typing_into_text() { + for type_tool in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let title = + s.execute("layer.newText", json!({"text": "Title", "size": 80, "position": [100, 120], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + // (Its row moves down as text layers are added.) + let row = |h: &Harness<'_, EffectcraftApp>| rect(h, &format!("timeline.layer.{solid}.row")).center(); + if type_tool { + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(1); + let at = rect(&h, "viewer.comp").center(); + click(&mut h, at, Modifiers::NONE); + assert_eq!(layers(&h), 3, "the Type tool started a text layer"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert_eq!(layers(&h), 2, "left empty, it is removed"); + click(&mut h, at, Modifiers::NONE); + } else { + h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); + h.run_steps(2); + } + type_text(&mut h, "Hello"); + // The Character panel formats the selection and keeps the edit: typing carries on. + key(&mut h, Key::ArrowLeft, Modifiers::SHIFT); + let range = edited(&h); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + assert_eq!(edited(&h), range, "type_tool {type_tool}: Character keeps the edit and its selection"); + key(&mut h, Key::End, Modifiers::NONE); + type_text(&mut h, "!"); + let (lid, text, ..) = edited(&h); + assert!(text.ends_with("Hello!"), "typing goes into the text: {text:?}"); + let at_row = row(&h); + click(&mut h, at_row, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "type_tool {type_tool}: clicking the Timeline ends the edit"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(solid)]); + assert!(!h.ctx.memory(|m| m.focused().is_some()), "the keyboard is the app's again"); + type_text(&mut h, "abc"); + assert!(!h.state().playback.playing); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Text(" ".into())); + h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, text, "type_tool {type_tool}: the text is unchanged"); + assert!(h.state().playback.playing, "type_tool {type_tool}: Space plays"); + assert_eq!(type_tool, matches!(h.state().ui.tool, effectcraft_ui_egui::state::Tool::Type), "the tool is kept"); + } +} + +/// #831: opening a formatting panel's tab behind Effects & Presets in its group (Properties, in +/// Undocked Panels) keeps the edit and its selected characters: the press on the tab used to +/// focus the shown panel and end the edit, after which Faux Bold styled the whole layer. +#[test] +fn opening_a_formatting_tab_keeps_the_text_selection() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = + s.execute("layer.newText", json!({"text": "Hello world", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + // Effects & Presets shown, Properties, Character and Paragraph behind it. + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Undocked Panels"})).unwrap(); + h.state_mut().session.execute("text.edit", json!({"layer": lid})).unwrap(); + h.state_mut().session.execute("text.setSelection", json!({"anchor": 0, "caret": 5})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find("properties.text.style").is_none(), "Properties is behind Effects & Presets"); + let tab = rect(&h, "panel.tab.Properties").center(); + click(&mut h, tab, Modifiers::NONE); + assert!(h.state().auto.find("properties.text.style").is_some(), "the tab opened Properties"); + assert_eq!(edited(&h), (lid, "Hello world".into(), 0, 5), "the edit and its selection are kept"); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let bold = rect(&h, "character.fauxBold").center(); + click(&mut h, bold, Modifiers::NONE); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert!(doc.style_at(1).faux_bold && !doc.style_at(7).faux_bold, "only \"Hello\" is bold"); +} + +/// #831: a click away and keys in one frame's input. The text gets the keys typed before the +/// click and none after it; those later keys don't run as shortcuts either (Delete would remove a +/// layer). From the next frame the keys are the app's: Space plays. +#[test] +fn keys_after_a_click_away_in_the_same_frame_skip_the_text() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Hello", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap(); + h.run_steps(2); + let row = rect(&h, &format!("timeline.layer.{solid}.row")).center(); + let key = |key: Key, pressed: bool| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([ + Event::Text("A".into()), + Event::PointerMoved(row), + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: row, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Text("x".into()), + key(Key::Space, true), + Event::Text(" ".into()), + key(Key::Space, false), + key(Key::Delete, true), + key(Key::Delete, false), + ]); + h.run_steps(3); + let comp = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA", "only the key before the click"); + assert!(h.state().session.state.text_edit.is_none(), "the click ended the edit"); + assert_eq!((comp(&h), h.state().playback.playing), (2, false), "the keys after it ran no shortcut"); + h.input_mut().events.extend([key(Key::Space, true), Event::Text(" ".into()), key(Key::Space, false)]); + h.run_steps(2); + assert!(h.state().playback.playing, "Space plays"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "HelloA"); +} + #[test] fn switching_from_type_to_selection_commits_and_moves_text_layer() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app()); @@ -277,3 +408,59 @@ fn type_tool_drag_makes_paragraph_text_with_box_handles() { assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size); assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing"); } + +/// #866: Layer ▸ New ▸ Text (menu or its shortcut) puts an insertion point in the comp, as After +/// Effects does, so typed keys reach Source Text (and the layer name follows) instead of running +/// shortcuts while the layer keeps the placeholder "Text". +#[test] +fn layer_new_text_menu_and_shortcut_start_typing_into_source_text() { + for via_shortcut in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + if via_shortcut { + key(&mut h, Key::T, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT); + } else { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newText", json!({})).unwrap(); + h.run_steps(2); + } + let (lid, text, _, _) = edited(&h); + assert_eq!(text, "", "no placeholder to type over (shortcut: {via_shortcut})"); + type_text(&mut h, "My First Project"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "Escape commits"); + let s = &h.state().session; + assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "My First Project", "typed text is the Source Text"); + assert_eq!(s.active_comp().unwrap().layer(LayerId(lid)).unwrap().name, "My First Project", "the name follows the text"); + } +} + +/// Double-clicking a text layer in the Timeline, on its name or on its bar, edits its text in the +/// Composition viewer with the whole text selected, as in After Effects (Enter renames). +#[test] +fn timeline_double_click_on_a_text_layer_edits_its_text() { + for target in ["row", "bar"] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "alpha beta"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center(); + click(&mut h, p, Modifiers::NONE); + click(&mut h, p, Modifiers::NONE); + let (layer, text, anchor, caret) = edited(&h); + assert_eq!(layer, lid, "{target}"); + assert_eq!((anchor.min(caret), anchor.max(caret)), (0, 10), "everything selected ({target})"); + assert!(h.state().auto.find(&format!("timeline.layer.{lid}.nameEdit")).is_none(), "no rename field ({target})"); + assert_eq!(text, "alpha beta"); + type_text(&mut h, "gamma"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "Escape commits ({target})"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})"); + } +} diff --git a/crates/ui-egui/tests/ui_theme_favorites.rs b/crates/ui-egui/tests/ui_theme_favorites.rs new file mode 100644 index 00000000..1b700f51 --- /dev/null +++ b/crates/ui-egui/tests/ui_theme_favorites.rs @@ -0,0 +1,68 @@ +use effectcraft_engine::{Session, config::MemoryConfig, prefs::Prefs}; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use std::sync::Arc; + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + h.event(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} +#[test] +fn favorites_validate_and_survive_settings_reload() { + let loaded = Prefs::from_json(r#"{"appearance":{"favoriteThemes":["dark","unknown","dark","light"]}}"#); + assert_eq!(loaded.appearance.favorite_themes, vec!["dark", "light"]); + let mut p = Prefs::default(); + for bad in [json!(["dark", "dark"]), json!(["unknown"]), json!([3]), json!("dark")] { + assert!(p.set("appearance.favoriteThemes", bad).is_err()); + assert!(p.appearance.favorite_themes.is_empty()); + } + p.set("appearance.favoriteThemes", json!(["light", "darker"])).unwrap(); + let loaded = Prefs::from_json(&p.to_json()); + assert_eq!(loaded.appearance.favorite_themes, p.appearance.favorite_themes); +} +#[test] +fn star_controls_save_and_favorite_menu_selects_the_family() { + let store = Arc::new(MemoryConfig::default()); + let mut session = Session { config: Some(store.clone()), ..Default::default() }; + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"appearance"})).unwrap(); + h.run_steps(3); + let p = at(&h, "settings.appearance.lightTheme.light"); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + let p = at(&h, "settings.appearance.favoriteThemes.light"); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["light"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + std::fs::create_dir_all(&path).unwrap(); + h.render().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap(); + } + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "dark"); + let p = at(&h, "settings.ok"); + click(&mut h, p, PointerButton::Primary); + let mut saved = Session { config: Some(store), ..Default::default() }; + saved.load_settings(); + assert_eq!(saved.prefs.appearance.favorite_themes, vec!["light"]); + h.state_mut().set_pref("appearance.favoriteThemes", json!(["darker", "light"])).unwrap(); + let p = at(&h, "header.appearance"); + click(&mut h, p, PointerButton::Secondary); + let p = at(&h, "header.favoriteTheme.light"); + assert!(p.y < at(&h, "header.favoriteTheme.darker").y); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "light"); + assert_eq!(h.state().session.prefs.appearance.dark_theme, "dark"); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dd9232d7..411bb22a 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,7 +1,7 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the //! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items -//! between shape layers (#227). +//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -243,3 +243,31 @@ fn timeline_colour_eyedropper_samples_the_comp() { assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); } } + +/// Ctrl+V with SVG markup on the system clipboard pastes it as a shape layer (After Effects +/// 26.3), even with layers on the app's clipboard: copying in the app replaces the system +/// clipboard, so the SVG was copied later (#738). +#[test] +fn ctrl_v_pastes_svg_markup_as_a_shape_layer() { + let (mut h, a, b, _) = two_shape_layers(); + click(&mut h, &format!("timeline.layer.{b}.row")); + h.input_mut().events.push(Event::Copy); + h.step(); + assert_eq!(h.state().session.state.clipboard.len(), 1, "a layer on the app's clipboard"); + let svg = r##""##; + h.input_mut().events.push(Event::Paste(svg.into())); + h.run_steps(3); + let c = h.state().session.active_comp().unwrap().clone(); + assert_eq!(c.layers.len(), 3, "{:?}", h.state().ui.status); + let top = &c.layers[0]; + assert!(top.id != LayerId(a) && top.id != LayerId(b)); + assert_eq!(top.name, "Shape Layer 3"); + assert_eq!(top.props.sub("contents").unwrap().groups().count(), 2); + assert_eq!(h.state().session.state.selected_layers, vec![top.id]); + // The app's own clipboard note pastes the copied layer as before. + h.input_mut().events.push(Event::Paste("EffectCraft: 1 layer".into())); + h.run_steps(3); + let c = h.state().session.active_comp().unwrap(); + assert_eq!(c.layers.len(), 4); + assert_eq!(c.layers[0].name, "Oval 2"); +} diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs index 20f861d4..ea0798d9 100644 --- a/crates/ui-egui/tests/ui_timeline_switches.rs +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -100,6 +100,37 @@ fn dragging_over_layer_switches_sets_them_all() { assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched"); } +/// Alt-click on an A/V feature sets the layer apart (the other layers hidden / locked), and the +/// next Alt-click brings the others back; the Switches column is not affected by Alt. +#[test] +fn alt_click_on_av_feature_switches_the_other_layers() { + let (mut h, ids) = harness(); + let alt_click = |h: &mut Harness<'_, EffectcraftApp>, id: String| { + let p = rect(h, &id).center(); + let modifiers = Modifiers::ALT; + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + }; + let steps = h.state().session.history.undo.len(); + alt_click(&mut h, format!("timeline.layer.{}.video", ids[1])); + assert_eq!(states(&h, &ids, video), [false, true, false, false]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + alt_click(&mut h, format!("timeline.layer.{}.video", ids[1])); + assert_eq!(states(&h, &ids, video), [true; 4]); + alt_click(&mut h, format!("timeline.layer.{}.lock", ids[2])); + assert_eq!(states(&h, &ids, |l| l.switches.locked), [true, true, false, true]); + alt_click(&mut h, format!("timeline.layer.{}.solo", ids[0])); + assert_eq!(states(&h, &ids, |l| l.switches.solo), [true, false, false, false]); + alt_click(&mut h, format!("timeline.layer.{}.switch.motionBlur", ids[0])); + assert_eq!(states(&h, &ids, motion_blur), [true, false, false, false]); +} + /// #284: pressing a stopwatch and dragging over the stopwatches below gives every property the /// state the first one got (animated or not), in one undo step; Alt+click still adds an /// expression and a click toggles one property. diff --git a/crates/ui-egui/tests/ui_toolbar_gradient.rs b/crates/ui-egui/tests/ui_toolbar_gradient.rs new file mode 100644 index 00000000..b5682ffa --- /dev/null +++ b/crates/ui-egui/tests/ui_toolbar_gradient.rs @@ -0,0 +1,140 @@ +//! The toolbar's Fill / Stroke swatch with Linear or Radial Gradient (#612, reported on Discord): +//! it opens the Gradient Editor on the selected shape layer's Gradient Fill / Gradient Stroke +//! Colors, as in After Effects, instead of Fill / Stroke Options. The words still open the +//! options, a Solid Color swatch the colour picker, Alt-click still steps through the kinds, and +//! with no such gradient selected the swatch opens the options as before. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::state::Tool; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// The Colors uid of the first `paint` ("gfill" / "gstroke") group in `g`, through nested groups. +fn colors(g: &PropGroup, paint: &str) -> Option { + g.groups().find_map(|x| if x.match_id == paint { x.get("colors").map(|p| p.uid) } else { colors(x, paint) }) +} + +/// A rectangle shape layer (selected unless `select` is false) with `items` added to its +/// contents, and the toolbar set with `tool` (shape.toolOptions). +fn setup(items: &[&str], tool: serde_json::Value, select: bool) -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + for kind in items { + s.execute("layer.addShapeItem", json!({"layer": l, "kind": kind})).unwrap(); + } + s.execute("shape.toolOptions", tool.clone()).unwrap(); + // The toolbar shows a selected shape layer's own Fill / Stroke kind (#732), so the layer gets + // the kind too. + if select && let Some(o) = tool.as_object() { + let mut p = o.clone(); + p.insert("layers".into(), json!([l])); + s.execute("shape.fillStroke", serde_json::Value::Object(p)).unwrap(); + } + s.state.selected_layers = if select { vec![LayerId(l)] } else { vec![] }; + s.state.selected_props.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + // With no shape layer selected, Fill / Stroke show while a shape tool is active. + if !select { + h.state_mut().ui.tool = Tool::Rectangle; + } + h.run_steps(4); + (h, l) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, modifiers: Modifiers) { + let p = center(h, id); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +fn layer_colors(h: &Harness<'_, EffectcraftApp>, l: u64, paint: &str) -> u64 { + colors(&h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props, paint).expect("the gradient") +} + +/// The options popup is open (its kind buttons are in the automation tree). +fn options_open(h: &Harness<'_, EffectcraftApp>, key: &str) -> bool { + h.state().auto.find(&format!("header.{key}Options.solid")).is_some() +} + +/// The reported bug: a Linear Gradient Fill swatch opened Fill Options. It opens the Gradient +/// Editor on the selected layer's Gradient Fill Colors, and OK sets them. +#[test] +fn a_gradient_fill_swatch_opens_the_gradient_editor() { + let (mut h, l) = setup(&["gfill"], json!({"fillType": "linear"}), true); + let uid = layer_colors(&h, l, "gfill"); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the swatch opens the Gradient Editor, not Fill Options"); + assert!(!options_open(&h, "fill"), "and not the options"); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the layer's Gradient Fill Colors"); + // Edit and save it: the property really changes. + let bar = h.state().auto.find("dialog.gradient.addColor").unwrap().rect; + let at = pos2(bar[0] + bar[2] * 0.5, bar[1] + bar[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + click(&mut h, "dialog.gradient.ok", Modifiers::NONE); + let v = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().value.clone(); + let Value::Gradient(g) = v else { panic!("not a gradient: {v:?}") }; + assert_eq!(g.colors.len(), 3, "the added stop was saved: {g:?}"); +} + +/// The Stroke swatch with Radial Gradient edits the Gradient Stroke's Colors (not the fill's). +#[test] +fn a_gradient_stroke_swatch_edits_the_gradient_stroke() { + let (mut h, l) = setup(&["gfill", "gstroke"], json!({"strokeType": "radial"}), true); + let uid = layer_colors(&h, l, "gstroke"); + click(&mut h, "header.stroke", Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + let target = h.state().gradient_draft().target.clone().expect("a target"); + assert_eq!((target.layer, target.prop), (l, uid), "on the Gradient Stroke's Colors"); +} + +/// With no shape layer selected the swatch opens the options (the kind new shapes get) as before. +/// (A selected shape layer shows its own kind, #732, so a gradient kind always has its gradient.) +#[test] +fn without_a_selected_gradient_the_swatch_opens_the_options() { + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), false); + click(&mut h, "header.fill", Modifiers::NONE); + assert_eq!(h.state().dialog, None, "no editor"); + assert!(options_open(&h, "fill"), "Fill Options"); +} + +/// The rest of the toolbar is unchanged: the words open the options, a Solid Color swatch opens the +/// colour picker, and Alt-click steps to the next kind. +#[test] +fn words_solid_swatches_and_alt_click_are_unchanged() { + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fillOptions", Modifiers::NONE); + assert!(options_open(&h, "fill"), "the word opens Fill Options"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "solid"}), true); + click(&mut h, "header.fill", Modifiers::NONE); + assert!(h.query_by_label("Color field").is_some(), "a Solid Color swatch opens the colour picker"); + assert_eq!(h.state().dialog, None); + let (mut h, _) = setup(&["gfill"], json!({"fillType": "linear"}), true); + click(&mut h, "header.fill", Modifiers::ALT); + assert_eq!(h.state().dialog, None, "Alt-click doesn't open the editor"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), "radial", "it steps Linear → Radial"); +} diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs new file mode 100644 index 00000000..9b48b953 --- /dev/null +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -0,0 +1,63 @@ +//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a +//! notice on the layer in the Composition viewer, instead of a black picture with no reason. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::render::FootageSource; +use effectcraft_render::Image; +use effectcraft_time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; + +/// A source that decodes nothing and, for `broken` movies, says why. +struct Stub(bool); + +impl FootageSource for Stub { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, _: &Footage) -> Option { + self.0.then(|| WHY.to_string()) + } +} + +fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { + let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let footage = Footage { + kind: FootageKind::Video, + path: "clip.mp4".into(), + width: 64, + height: 36, + has_video: true, + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(4); + (h, clip) +} + +#[test] +fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { + let (h, clip) = harness(true); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(WHY), "{notice:?}"); +} + +#[test] +fn readable_footage_has_no_notice() { + let (h, clip) = harness(false); + assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); + assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bdb4189d..07036ca8 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -162,6 +162,56 @@ fn viewer_press_prefers_a_selected_layer_under_the_pointer() { assert_eq!(h.state().session.state.selected_layers, vec![front], "nothing selected: the topmost layer"); } +/// Each composition keeps its Resolution, saved with the project as in After Effects: chosen for +/// comp A in the viewer's popup, it is still A's after showing comp B, costs no undo step, keeps +/// A's cached frames and is read back from the saved file (#417). +#[test] +fn each_comp_keeps_its_viewer_resolution() { + use effectcraft_ui_egui::state::Resolution; + let mut h = harness(); + let a = h.state().session.active_comp_id().unwrap(); + let content = |h: &Harness<'_, EffectcraftApp>, c| { + let s = h.state(); + s.frames.content_of(&s.session.project, s.session.revision, c) + }; + let (before, steps) = (content(&h, a), h.state().session.history.undo.len()); + for id in ["viewer.resolution", "viewer.resolutionItem.1"] { + let at = rect(&h, id).center(); + click(&mut h, at); + } + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert_eq!(h.state().session.project.comp(a).unwrap().resolution, Resolution::Half); + assert_eq!(h.state().session.history.undo.len(), steps, "no undo step"); + assert_eq!(content(&h, a), before, "the comp's cached frames stay valid"); + // Comp B starts at Auto and gets its own (here through the control channel's `ui.set`). + h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap(); + h.run_steps(3); + let b = h.state().session.active_comp_id().unwrap(); + assert_ne!(a, b); + assert_eq!(h.state().viewer_res(), Resolution::Auto); + let (req, _reply) = effectcraft_ui_egui::control::ControlRequest::new("ui.set", json!({"viewer": {"res": "quarter"}})); + let ctx = h.ctx.clone(); + let _ = effectcraft_ui_egui::control::handle(h.state_mut(), &ctx, &req); + assert_eq!(h.state().viewer_res(), Resolution::Quarter); + let ctx = h.ctx.clone(); + assert_eq!(effectcraft_ui_egui::control::inspect(h.state(), &ctx)["ui"]["viewer"]["res"], json!("Quarter")); + // Back to comp A: its resolution again. + h.state_mut().session.open_comp(a); + h.run_steps(3); + assert_eq!(h.state().viewer_res(), Resolution::Half); + assert!(h.state().viewer_scale(1.0, 1.0) == 0.5); + // Saved and opened again. + let path = std::env::temp_dir().join(format!("ec-viewer-res-{}.ecproj", std::process::id())); + h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::default(); + s.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + s.open_comp(b); + let app = EffectcraftApp::new(s); + assert_eq!(app.viewer_res(), Resolution::Quarter); + assert_eq!(app.session.project.comp(a).unwrap().resolution, Resolution::Half); +} + #[test] fn bottom_bar_in_after_effects_order() { let h = harness(); @@ -601,6 +651,32 @@ fn fill_options_paint_the_next_shape() { assert_eq!(gfill.get("opacity").unwrap().value.as_f64(), 40.0); } +/// With a shape layer selected, Fill Options' None switches its fill off and Solid Color turns +/// it back on, and the Fill swatch shows the layer's kind (#732). +#[test] +fn fill_options_change_the_selected_shape_layer() { + let mut h = harness(); + let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.run_steps(2); + let fill_on = |h: &Harness<'_, EffectcraftApp>| { + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + layer.props.group("contents/group/contents/fill").unwrap().enabled + }; + assert!(fill_on(&h)); + for (kind, on) in [("none", false), ("solid", true)] { + let at = rect(&h, "header.fillOptions").center(); + click(&mut h, at); + let at = rect(&h, &format!("header.fillOptions.{kind}")).center(); + click(&mut h, at); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(fill_on(&h), on, "{kind}"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), kind); + } +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); @@ -1852,7 +1928,7 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); // (egui's font atlas needs 1024.) h.input_mut().max_texture_side = Some(1024); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]); for _ in 0..400 { h.step(); @@ -1861,7 +1937,7 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { } std::thread::sleep(std::time::Duration::from_millis(5)); } - assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); + assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().viewer_res()); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } @@ -1948,3 +2024,67 @@ fn vertex_tools_edit_motion_path_keyframes() { click(&mut h, quarter); assert_eq!(keys(&h).len(), 3); } + +/// #882: at 100 % (and 200 %) on a scaled display the frame's corner sits on a physical pixel, so +/// every comp pixel covers whole screen pixels instead of being blended with its neighbour. +#[test] +fn frame_sits_on_the_physical_pixel_grid() { + for ppp in [1.0f32, 1.25, 1.5] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "width": 301, "height": 201, "duration": 1})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + for (mag, pan) in [(1.0f32, [0.3f32, 0.37]), (2.0, [-0.45, 0.2])] { + h.state_mut().ui.viewer.zoom = Some(mag / ppp); + h.state_mut().ui.viewer.pan = pan; + h.run_steps(2); + let c = rect(&h, "viewer.comp"); + for v in [c.min.x * ppp, c.min.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}, {mag}×: corner at {v} physical px"); + } + assert!((c.width() * ppp - 301.0 * mag).abs() < 1e-2, "ppp {ppp}, {mag}×: {} physical px wide", c.width() * ppp); + } + } +} + +/// Magnification ▸ Fit up to 100% keeps fitting as the panel changes size (Maximize Panel), like +/// Fit, and never magnifies past 100%. +#[test] +fn fit_up_to_100_follows_the_maximized_panel() { + use effectcraft_ui_egui::dock::PanelKind; + let pick = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect(h, "viewer.magnification").center(); + click(h, at); + let at = rect(h, "viewer.magnification.1").center(); + click(h, at); + assert_eq!(h.state().ui.viewer.zoom, None, "a fit mode, not a fixed magnification"); + assert!(h.state().ui.viewer.fit_up_to_100); + h.run_steps(2); + }; + // A comp larger than the panel: it fills the docked panel, then the maximized one. + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Big", "width": 3840, "height": 2160, "duration": 4})).unwrap(); + EffectcraftApp::new(s) + }); + h.run_steps(3); + pick(&mut h); + let docked = rect(&h, "viewer.comp").width(); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + let area = rect(&h, "viewer.area"); + let maximized = rect(&h, "viewer.comp"); + assert!(maximized.width() > docked + 100.0, "{docked} -> {}", maximized.width()); + assert!(area.contains_rect(maximized), "{maximized:?} in {area:?}"); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert_eq!(rect(&h, "viewer.comp").width(), docked, "and back when the panel is restored"); + // A comp smaller than the panel stops at 100%. + let mut h = harness(); + pick(&mut h); + let ppp = h.ctx.pixels_per_point(); + assert!(rect(&h, "viewer.comp").width() <= 640.0 / ppp + 0.01); + h.state_mut().toggle_maximize(PanelKind::Composition); + h.run_steps(3); + assert!((rect(&h, "viewer.comp").width() - 640.0 / ppp).abs() < 0.01); +} diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index fa97b571..d706a382 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -170,7 +170,15 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { let mut frames2 = Frames::default(); frames2.set_remote(Some(worker2.clone())); assert!(frames2.remote_disk()); - let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + let bar = loop { + let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + if !bar.is_empty() { + break bar; + } + assert!(std::time::Instant::now() < until, "disk-cache indicator did not finish hashing"); + std::thread::sleep(std::time::Duration::from_millis(1)); + }; assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame"); frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts); frames2.dispatch_remote(); diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 7b1ca768..697ff2a2 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -177,7 +177,6 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.state_mut().session.execute("layer.select", json!({"layers":[id.0]})).unwrap(); h.state_mut().session.execute("effect.apply", json!({"effect":"Gaussian Blur"})).unwrap(); h.state_mut().session.execute("view.fastPreviewMode", json!({"mode":"adaptive"})).unwrap(); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; let ctx = h.ctx.clone(); effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.panel", json!({"panel":"effectControls"})).unwrap(); h.run_steps(3); @@ -188,12 +187,12 @@ fn effect_scrubbing_uses_adaptive_resolution_and_settles_on_release() { h.run_steps(2); assert!(h.state().ui.viewer.property_interacting); assert!(h.state().viewer_scale(1.0, 1.0) < 1.0); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + h.state_mut().session.execute("view.res.full", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); pointer(&mut h, a + vec2(30.0, 0.0), false, Modifiers::NONE); h.run_steps(2); assert!(!h.state().ui.viewer.property_interacting); - h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Auto; + h.state_mut().session.execute("view.res.auto", json!({})).unwrap(); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } @@ -430,3 +429,82 @@ fn timeline_selected_effect_row_highlights() { assert!(h.state().session.state.selected_props.contains(&(id, fx)), "the click selects the effect"); assert!(highlighted(&h), "the selected effect's row is highlighted"); } + +/// Right-clicking a layer in the Timeline keeps the selection when the layer is part of it, +/// so the context menu's commands act on every selected layer; right-clicking an unselected +/// layer selects only it (After Effects). Reported on Discord: Pre-compose and Delete from the +/// menu acted on the right-clicked layer only (#878). +#[test] +fn timeline_context_menu_acts_on_the_whole_selection() { + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + let row = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) + }; + let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center(); + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + // Outline row: Delete removes both selected layers. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[1]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[1]]); + h.get_by_label("Delete").click(); + h.run_steps(3); + let left: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + assert_eq!(left, vec![ids[2], ids[3]]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // Layer bar: same, with Duplicate. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[2].0, ids[3].0]})).unwrap(); + h.run_steps(3); + let p = bar(&h, ids[3]); + right_click(&mut h, p); + h.get_by_label("Duplicate").click(); + h.run_steps(3); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 6); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + // An unselected row: right-clicking selects only it. + h.state_mut().session.execute("layer.select", json!({"layers":[ids[0].0, ids[1].0]})).unwrap(); + h.run_steps(3); + let p = row(&h, ids[3]); + right_click(&mut h, p); + assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]); +} + +/// The Project panel's Move to Project Root acts on every selected item when the right-clicked +/// item is part of the selection, as Delete and Label do. +#[test] +fn project_context_move_to_root_acts_on_the_whole_selection() { + let mut h = harness(); + let solids: Vec = { + let p = &h.state().session.project; + p.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).map(|i| i.id.0).collect() + }; + assert!(solids.len() >= 2); + let folder = h.state_mut().session.execute("project.newFolder", json!({"name":"Bin"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": solids, "folder": folder})).unwrap(); + h.state_mut().ui.project_open_folders.insert(folder); + h.state_mut().session.execute("project.select", json!({"items": [solids[0], solids[1]]})).unwrap(); + h.run_steps(3); + let p = rect(&h, &format!("project.item.{}.name", solids[1])).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_label("Move to Project Root").click(); + h.run_steps(3); + let project = &h.state().session.project; + for id in &solids[..2] { + assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); + } +} diff --git a/docs/agents.md b/docs/agents.md index 8bf3cc87..9c9f9fce 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -107,7 +107,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | | `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | -| `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. | +| `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | | `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | @@ -123,7 +123,8 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `render_preview` | Render a PNG without writing a file (`comp`, `time`, `max_side`, `transparent`). | Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are -referenced by id or name, and default to the active comp. Property paths come from `get_layer`, for +referenced by id or name, and default to the active comp. An id may also be sent as a string (`"98"`): +an all-digit string that matches no name is taken as the id. Property paths come from `get_layer`, for example `transform/position`, `transform/opacity`, `effects/#1/blurriness` or `@57` (by uid). Times are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or stretched. @@ -144,7 +145,7 @@ carries outside-range warnings; the existing objects from `keys.set` and `prop.g also report both bases and warnings. `prop.get.time` is comp seconds. Separated Position `prop.set` retains per-axis replies; for these commands, `path` or `prop` identifies a property, not a group. -Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds. +Set offsets with `layer.timing {layer, start: 10.5}`; `in`, `out` and `delta` also use comp seconds; `inDelta` / `outDelta` trim each layer from its own in/out point. For an unstretched layer starting at 10.5 s in a 30 fps comp, `prop.addKey {layer: "A", path: "transform/opacity", time: 15.3, timeBase: "comp", value: 0}` writes a key at comp 15.3 s (layer 4.8 s), matching @@ -219,6 +220,10 @@ build real projects through these interfaces; they are worked examples of everyt * **Nested comps**: `comp.settings {preserveFrameRate, preserveResolution}` (Composition Settings ▸ Advanced); `comp.info` / `get_comp` report both. A precomp layer shows nothing outside its nested comp's span. +* **Viewer Resolution** belongs to each comp and is saved with the project, as in After Effects: + `view.res.full|half|third|quarter|auto {comp?}` or `view.res.custom {factor: 1..40, comp?}` + (headless too; no undo step, the project becomes modified). `comp.info` reports `resolution` + and `resolutionFactor` (`null` for Auto). * **Several footage items at once**: `file.newCompFromSelection {single, dimensionsFrom, duration, sequence, overlap, overlapDuration, transition, addToRenderQueue}` is one undo step (select the items first: `project.select`). @@ -511,12 +516,19 @@ whole layer, or only characters `range: [start, end]` (character indices): chara work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its -styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"gothic"}` filters -by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and +styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name +(`{"query":"gothic"}` filters by name, `{"variable":true}` keeps only variable fonts, like the font +menu's Variable Fonts filter; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType options a font draws with its own glyphs. A tool with its own font library can hand it over without installing it: folders listed in `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders. +On Linux and the BSDs the system font folders are `/usr/share/fonts`, `/usr/local/share/fonts`, +`~/.local/share/fonts`, `~/.fonts`, `$XDG_DATA_HOME/fonts`, `fonts` in each `$XDG_DATA_DIRS` folder, +every `` the fontconfig configuration names (`/etc/fonts/fonts.conf` with its includes, +`~/.config/fontconfig/fonts.conf`) and, in the Flatpak, the host's fonts under `/run/host`. The +Flatpak can't see other folders; grant one with +`flatpak override --user --filesystem=:ro --env=EFFECTCRAFT_FONT_DIRS= ai.storyteller.effectcraft`. In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a @@ -532,7 +544,10 @@ Scripts that build ScriptUI windows publish them to the session; agents drive th 3. `scriptui.click {"widget": "ok"}` presses a button / toggles a checkbox / picks a radio button or tab; `scriptui.set {"widget": "#4", "value": "Shot_"}` types into edit text, moves a slider or picks a list item (index or text); `scriptui.close {"result": 2}` closes. Controls are addressed - by id, `#id`, `properties.name` or text; `window` can be omitted when one window is open. Each + by id, `#id`, `properties.name` or text; `window` can be omitted when one window is open. + `scriptui.mouse {"widget": "pad", "type": "mousedown", "x": 10, "y": 20}` sends a mouse event + (`mousedown`, `mouseup`, `mousemove`, `mouseover`, `mouseout`, `click`; `x`/`y` relative to the + control, `button`, `detail`, `shiftKey`…) to the script's `addEventListener` listeners. Each returns the handler run's `{ok, output, error}`. A dialog's `show()` waits for the user: the `script.run` / `file.runScript` reply carries diff --git a/docs/architecture.md b/docs/architecture.md index 9e6fc0e7..72d8b734 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,10 @@ XML). Saving goes through `Project::to_file_json`, which checks that the text re anything is written (JSON can't hold NaN or infinity), and files are written atomically. Opening a file that a newer EffectCraft saved shows a warning: fields this version doesn't know are dropped if it saves the project. Save, Save As, Save a Copy and Increment and Save keep the -file's format (`.ecprojx` stays XML). +file's format (`.ecprojx` stays XML). Next to `savedBy` the file records the active comp and the +open comp tabs (`activeComp`, `openComps`), which opening restores; they are editor state, not +part of `Project`, so older files (and ids that no longer name a comp) open on the first comp and +older versions ignore them. `schema` changes when a saved value changes meaning; a project from an older schema is converted when it is opened (`effectcraft_engine::upgrade_effects`, which also refreshes effect instances @@ -191,7 +194,10 @@ are dropped; running jobs finish into the cache. When the budget (Settings ▸ M full the least recently shown frames go first. The timeline's green bar counts cached frames of the current content and viewer settings. Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above -50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages +50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). The Resolution is the comp's +(`Comp::resolution`, saved with the project): setting it is no undo step (undo, redo and history +jumps carry the current values over) and, like the Shy switches, it is outside +`Comp::same_pixels`, so cached frames stay valid. Below 100 % the viewer averages instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one texel per screen pixel. @@ -388,6 +394,9 @@ their on-screen scale, as text is vector in After Effects; on shape layers and v switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points. +Dragging a layer bar's in or out edge with several layers selected trims every selected layer by +the same amount from its own in/out point (`layer.timing {inDelta | outDelta}`), clamped to the +comp and to one frame of layer, as one undo step. **Nested comps** show nothing before they start or after they end (`Comp::covers`), even when the precomp layer is trimmed or remapped past them. A comp with **Preserve frame rate when nested @@ -540,10 +549,24 @@ text, disables the expression and shows a warning, like After Effects. `sampleIm sampled layer through the evaluating renderer's footage source (`EvalCtx::footage`), cached per thread and frame; `footage(name)` reads data footage (JSON, CSV, TSV imported with File ▸ Import, text kept in the project) through `sourceData`, `sourceText` and `dataValue`. `expr.errors` -lists failing expressions for the viewer's error bar. Each thread keeps one boa context; boa +lists failing expressions on demand. The viewer's error bar uses the same checks against an +immutable project snapshot, after 150 ms without editing, pointer interaction or playback. +Native builds run these automatic checks on one persistent worker; layout and paint only read +completed results. Requests are coalesced per comp, cancelled between properties when obsolete, +and bounded to eight comp entries. Results must match the current project, revision and time. +Disabling Show Expression Error Banner cancels automatic checks. Non-solo layers still receive +diagnostics, including disabled expressions with syntax errors. The single-threaded web build +shares the cache and debounce but evaluates one scan on the UI thread after interaction stops. +Each thread keeps one boa context; boa leaves values on its stack whenever an exception unwinds through call frames (every pending host request does), so a run that hits the stack limit gets a fresh context and is retried once. +Snapping memoizes expression values while collecting one immutable set of targets. Bounds, +vertices and transforms share those values; the cache is discarded before the next query, so +time changes and edits always use current geometry. Native timeline disk-cache indicators also +request missing content hashes in the background, coalescing edits per comp. Their blue bars +appear after the key is ready instead of serializing composition content during painting. + ## 5a. Scripting `effectcraft-script` runs JavaScript (boa, a fresh context per run; the Script Console keeps one) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d76716fd..f5487981 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -134,7 +134,10 @@ Viewer state that agents drive headless too: `view.snapping`, `view.snappingOpti `header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed Compositions and Text Layers), `view.channel {channel, colorized?}`, `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, -`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, +`view.fastPreviewMode {mode}`, `view.res.full|half|third|quarter|auto {comp?}` and +`view.res.custom {factor, comp?}` (the comp's Resolution, saved with the project but no undo +step; `ui.set {viewer: {res}}` sets the active comp's, and `ui.inspect` reports it as +`ui.viewer.res`), `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), `shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), `shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options), diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b57b84b5..5f26587c 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | | **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | -| Animation presets | **8** original text-animator presets against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`; the After Effects `Presets` folder | +| Animation presets | **31** original presets (8 text-animator, 23 Transitions) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes | **Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a diff --git a/docs/effects.md b/docs/effects.md index 8a02b489..0bfc6a7b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,7 +3,7 @@ -EffectCraft ships 307 effects with 3029 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 307 effects with 3032 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 280 effects also run on the GPU compositor with identical results. - **32**: 307 effects process 32-bit float (HDR, overbright) pixels without clamping. @@ -307,7 +307,7 @@ Effects are addressed by id (`ec..`) or display name in commands | 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented | | Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | | Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | -| CC Cylinder | `ec.perspective.cccylinder` | 12 | GPU | 32 | Implemented | +| CC Cylinder | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented | | CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | | CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | | CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 66f18b47..67b13203 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -44,13 +44,13 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | | Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | | Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | -| SVG | R | yes | — | good; pasting SVG/AI as shape layers (26.3) missing | +| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't | | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | -| WebP | — | yes | — | beyond After Effects | -| Targa `.tga` | R/W | none | none | Media Browser lists it but import refuses (gap 17) | -| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Same; common in film VFX | -| Radiance `.hdr` | R/W | none | none | Same | +| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | +| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | | HEIF / AVIF | R (platform) | none | none | — | @@ -64,7 +64,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | AV1 | R | yes | yes (MP4 `av01`, WebM) | beyond After Effects' export; no B-frames / CDEF | | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | -| MXF (OP1a / OP-Atom) | R/W | none (FilmCraft reads it; not wired) | none | Media Browser lists it | +| MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | | AVI | R | none | none | Media Browser lists it | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | @@ -78,9 +78,9 @@ trips and renders, checked by tests, not against After Effects), **partial** (na |---|---|---|---| | glTF / GLB | R | yes (meshes, PBR, skins, animations) | good | | OBJ / MTL | R | yes | good | -| FBX | R (`usdFbx`) | none | gap 13 | +| FBX | R (`usdFbx`) | yes (7.x binary and ASCII: meshes with normals, UVs and per-polygon materials, the Model hierarchy with pivots and pre/post rotation, Lambert/Phong materials with embedded or relative textures, axes turned to Y-up; no animation, skinning, cameras or lights; 6.x rejected) | partial | | USD / USDZ | R (`usd_plugins`) | none | gap 13 | -| STL | R (`usdStl`) | none | gap 13 | +| STL | R (`usdStl`) | yes (binary and ASCII, VisCAM and Magics facet colours; Z-up turned to Y-up) | good | | Substance 3D materials `.sbsar` | R (26.0) | none | gap 13 | ## Interchange and data @@ -96,18 +96,19 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | | Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | -## Render queue outputs (`OutputFormat::ALL`, 12) +## Render queue outputs (`OutputFormat::ALL`, 13) -H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR -sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: uncompressed -or lossless single-file video (#372), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware +H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM; +#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF. +Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed +single-file video (Animation / PNG codecs), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file (#325). ## Estimate **≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / -HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD / STL and Substance 25–45 h, +HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h, relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index e9366c73..b02b108d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -63,25 +63,25 @@ are grouped by area; the issue tracker has the rest. | 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames, #304, #329 slow scrubbing, #554 / #504 / #335 audio late or crackling, #586 puppet lag | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | -| 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 7 | **Animation presets: 31 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 31 original presets (8 text-animator, 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades; #757) and read only our `.ecpreset`. Missing: Behaviors, Backgrounds, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | -| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | +| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 Working Gamma 2.4 (in, also for float footage: #750); #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | | 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | -| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | -| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | +| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | +| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | -| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | -| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | -| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | +| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | +| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -129,6 +129,27 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the + `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open + the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They + check that OK sets the edited stops (added, dragged, recoloured in the picker, down to a single + colour stop) in one undo step, at the composition time the editor opened (also on offset and + stretched layers), and that OK without a change, Cancel and Escape change nothing; Escape with + the picker open closes only the picker. On an animated gradient only the keyframe at that time + changes, with a warning when its stop counts no longer match a neighbour's. Delete never + removes the last stop, a layer or a stop while typing; a stop moved past another by its + Location keeps its selection and fields; a layer deleted while editing is reported; and + `prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe` + and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round + trip, and stops staying in order, and selected, while they move. The `ui_gradient_handles` + tests, checked headlessly on Windows, drag a selected Gradient Fill's start and end points in + the Composition viewer: one undo step, the layer stays, a point inside a moved and scaled group + converts to the group's space, and the handles show only for a selected gradient. +- Regression evidence for the toolbar's gradient swatches (#612, reported on Discord): the + `ui_toolbar_gradient` input tests, checked headlessly on Windows, click a Linear Fill swatch and + a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill + / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, + and the words, Solid swatches and Alt-click are unchanged. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template @@ -198,7 +219,12 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie gate is a P0. - Fuzz the inputs we don't control: project files, imported media and documents, expressions, scripts, control-channel and MCP requests. Every crash found becomes a regression test - (AGENTS.md "Never crash"). + (AGENTS.md "Never crash"). Project files are fuzzed (#779): a seeded, dependency-free mutation + fuzzer runs in `cargo test -p effectcraft-engine` (`fuzz_file_open_never_crashes`, `.ecproj` + JSON and `.ecprojx` XML through File ▸ Open, the first queries and a thumbnail render; longer + runs via `EFFECTCRAFT_FUZZ_ITERS` / `EFFECTCRAFT_FUZZ_SECS`). It found one crash, deeply nested + XML overflowing the stack, now refused. Imported media and documents, expressions, scripts and + control-channel / MCP requests are still to do. - Run the gate on every pull request in CI (gap 4): today no workflow runs the tests on PRs. - Fix the open crash reports first (gap 3). - Done when: the gate is green on stable, runs on every pull request, and the fuzzers run diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 922f75e6..145e5b24 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -22,7 +22,7 @@ monitors are missing, and two GPU start-up failures are open. | Video output devices | Mercury Transmit to AJA, Blackmagic, second display | second preview window only (Video Preview) | same | same | — | No SDI/HDMI device output | | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | -| Multiple monitors | panels and windows across screens | floating panels within one window only | same | same (#486) | — | gap 20 | +| Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | | Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late on Linux (#554) and after a start offset (#504) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ff70ca62..61dfe5f1 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first measurement of every catalog against the After Effects 2026 26.5 language set) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Ukrainian panel catalog recounted after it caught up with the Chinese ones) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -22,7 +22,7 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea **618** fixed entries. The generated Effect menu (effect names) is deliberately left in English. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese, Simplified and Traditional - Chinese and Ukrainian. + Chinese, Ukrainian and Czech. - **Not translated anywhere yet:** effect names and their ≈ 1,650 parameter names (`crates/effects`), engine-generated text (property names, errors, undo labels), the Home screen's unwrapped literals (#574), Learn tutorials and documentation. @@ -60,12 +60,13 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | No Hangul fallback probe: boxes on macOS (#599) | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (2): +Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,081 (≈ 28%): menus 618/618, panels 463 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,015 (≈ 26%): menus 618/618, panels 397 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,113 (≈ 29%): menus 618/618, panels 495/496 | partial | 6–9 h + review | +| Czech | `cs` | 1,114 catalog entries (≈ 29%): menus 618/618, panels 496/496 (current Chinese catalog); added after the measurement above | All existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | After Effects also ships Italian and Russian, which EffectCraft does not (8–12 h each). @@ -87,3 +88,4 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/plugins.md b/docs/plugins.md index d24c078b..fc7c0b8f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -14,7 +14,7 @@ Guide): | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | -| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | Not understood: a script that starts with one doesn't parse | Gap | +| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | @@ -84,9 +84,12 @@ Supported controls: `panel`, `group`, `button`, `iconbutton`, `statictext`, `edi `tabbedpanel` / `tab` and `image`, with ScriptUI's automatic layout (`orientation`, `alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`), resource strings, event handlers (`onClick`, `onChange`, `onChanging`, `onClose`, `addEventListener`) and `onDraw` with -ScriptUIGraphics. Mouse events (`mousedown`, `mousemove`…) aren't sent to scripts yet. Agents use -a panel like a user: `scriptui.list`, `scriptui.get` (its controls), `scriptui.click`, -`scriptui.set` and `scriptui.close`; every control has the automation id +ScriptUIGraphics. Mouse events (`addEventListener("mousedown" | "mouseup" | "mousemove" | +"mouseover" | "mouseout" | "click", handler, capture?)`) reach the control under the pointer and +its containers (capture, target, bubble; `stopPropagation()`), with a MouseEvent carrying +`clientX`/`clientY` relative to the target, `button`, `detail` (click count) and the modifier +keys. Agents use a panel like a user: `scriptui.list`, `scriptui.get` (its controls), +`scriptui.click`, `scriptui.set`, `scriptui.mouse` and `scriptui.close`; every control has the automation id `scriptui..`. The bundled sample `Layer Tools.jsx` (`crates/engine/scripts/ScriptUI Panels/`) and the Ease Presets extension below are longer examples. diff --git a/docs/preferences.md b/docs/preferences.md index 157eca99..3b92782c 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -57,10 +57,12 @@ Commands (CLI, MCP, control channel): - `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). - `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance - `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.favoriteThemes`: a list of unique theme ids (`dark`, `darker`, `light`), empty by default. Hover a theme row to star it; saved favorites keep an accent-colored star. Right-click the appearance button for Appearance Settings and favorites grouped light first, then dark. Selecting a favorite fixes its appearance family and preserves the opposite family choice; left-click still cycles modes. Settings OK saves favorites and Cancel restores them. Unknown or duplicate ids are rejected by `prefs.set` and removed when loading old settings. - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness -- `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the - display's own scaling), applied as soon as it is chosen +- `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from + 75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is + dragged or entered - `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths - `grids.gridColor`: Color - `grids.gridSpacing`: Gridline Every @@ -94,7 +96,9 @@ Commands (CLI, MCP, control channel): how much of a position motion path the viewer draws (all, none, N seconds or N keyframes around the current time) - `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp -- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear +- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = averaged + (bilinear, mipmapped) when the frame is shown below 100 %; magnified frames show their pixels as + blocks in both modes, and the frame sits on the physical pixel grid, so 100 % is exact - `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management - `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour, Effect Controls / Properties the layer's @@ -223,3 +227,17 @@ checkpoint after a client restart. Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`, `file.clearRecent`, `file.revert`, `file.incrementAndSave`. + +## Logs + +The desktop app writes its log records to standard error and to `Logs/effectcraft.log` in the +config directory. Each launch moves the previous log to `effectcraft.1.log` (and that one to +`effectcraft.2.log`), so the log of a run that crashed survives the next start; a log stops +growing at 16 MiB. A launch from the Dock, the Start menu or a launcher has no terminal, so +attach these files to a bug report. Help ▸ Reveal Logging File shows this run's log (or Help ▸ +Enable Logging's `EffectCraft Log.txt` while that is on, which keeps every EffectCraft record +across launches). + +By default the log has `info` records from EffectCraft and warnings and errors from everything +else. `RUST_LOG` replaces that with env_logger-style directives: `debug`, +`warn,effectcraft_render=trace`, `wgpu_core=info`. The control-channel token is never logged. diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index fef88efb..f891da74 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -18,11 +18,13 @@ our own scripts; no real-world script library has been run against both apps. | Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference | good | `crates/expr/src/tests.rs`; behaviour of edge cases unmeasured (G1) | | Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | -| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | +| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | -| `#include`, `#target` directives | yes | a script starting with one doesn't parse | missing | plugins.md; common in real scripts | +| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | | Keyboard shortcuts for scripts | yes | no | missing | plugins.md | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c8ef95a0..60ae74d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -681,8 +681,15 @@ Fixes from measured reports: too large (#390). - The viewer's Auto resolution renders the pixels the magnification needs (Full above 50 %), and frames shown below 100 % are averaged (GPU frames through mip levels, CPU frames by a whole - factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification - are not remembered between launches. + factor) instead of minified bilinearly (#417). +- Each composition keeps its Resolution (Auto, Full, Half, Third, Quarter or Custom) in the + project, as in After Effects: switching to another comp and back, or saving and reopening the + project, shows the same resolution. Changing it marks the project modified but adds no undo + step (undo and redo keep it) and keeps the comp's cached frames; projects saved before open + with Auto. View ▸ Resolution and the panel's popup run `view.res.full|half|third|quarter|auto` + and `view.res.custom {factor}`, which now work headless too; `comp.info` reports `resolution` + and `resolutionFactor` (#417). Not yet: the magnification is not saved per comp, and the + Composition Settings dialog has no Resolution popup. ### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel @@ -889,7 +896,11 @@ saved warns that what this version doesn't know is lost on saving (`file.open` r `savedBy`). Saving checks that the file reads back first, so a value JSON can't hold (NaN) fails the save with a message instead of writing a project that can never be opened (the file on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`) -XML; a file that isn't a project says so by name. +XML; a file that isn't a project says so by name. A corrupt or hostile project opens within the +limits the commands use (#788): compositions, solids and footage at most 30,000 × 30,000 px (as in +After Effects), frame rates 0.001–1000 fps, pixel aspect 0.1–10, compositions at least one frame +long and 2–64 motion-blur samples; `file.open` lists what it corrected (`repaired`) and a toast +names it. ### Update: M3.13 menu bar diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd807ebc..3654f61b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,8 +17,8 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing | -| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); copy frame to clipboard (26.3) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties | +| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | @@ -26,11 +26,11 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | -| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); variable-font filter (26.3) | +| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) | | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; focus labels tested | 20% | No screen-reader pass, keyboard-navigation audit or contrast check | +| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate diff --git a/packaging/web/package.sh b/packaging/web/package.sh index c3ee5fe2..89d767ea 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,6 +12,14 @@ set -euo pipefail HERE="$ROOT/packaging/web" if [ "${1:-}" != "--skip-build" ]; then + # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the + # crate's exact version. Locally, `cargo xtask web` says which version to install. + if [ -n "${CI:-}" ]; then + WASM_BINDGEN_VERSION="$(sed -n 's/^const WASM_BINDGEN: &str = "\(.*\)";$/\1/p' "$ROOT/xtask/src/main.rs")" + if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF "$WASM_BINDGEN_VERSION"; }; then + cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + fi + fi (cd "$ROOT" && cargo xtask web) fi