Compare commits
No commits in common. "fix/212-puppet-input-cache" and "ci-packaging-lint" have entirely different histories.
fix/212-pu
...
ci-packagi
|
|
@ -1,48 +0,0 @@
|
|||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
|
@ -47,10 +47,6 @@ jobs:
|
|||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/effectcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/effectcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.effectcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.effectcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.effectcraft.metainfo.xml"
|
||||
|
|
|
|||
|
|
@ -112,9 +112,6 @@ jobs:
|
|||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
EFFECTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
EFFECTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: effectcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'EffectCraft'
|
||||
foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'effectcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "effectcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok effectcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
*~
|
||||
plan/
|
||||
target/
|
||||
/target
|
||||
/dist
|
||||
/log
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -105,15 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
|||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
[craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md).
|
||||
Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified,
|
||||
as PhotoCraft does with `cargo xtask corpus`.
|
||||
|
||||
## See also
|
||||
|
||||
- [docs/gaps.md](docs/gaps.md): where EffectCraft falls short of After Effects and the prioritised workstreams (G1–G9); read it before choosing work
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things
|
||||
- [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline
|
||||
- [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks
|
||||
|
|
|
|||
|
|
@ -70,34 +70,6 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
|||
(sidecars `.attribution`).
|
||||
- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly).
|
||||
|
||||
## Trained models (optional downloads, not bundled)
|
||||
|
||||
The repository and the installers contain no model weights. These models are downloaded only when
|
||||
a user asks (Settings ▸ Roto Brush, Settings ▸ Face Tracking, or the `roto.model.*` /
|
||||
`face.model.*` commands), unmodified from their authors' official location, checked against a
|
||||
pinned SHA-256 and stored in the user's `models` folder with a `<file>.NOTICE.txt` naming their
|
||||
authors, licence and source. EffectCraft runs them with its own Rust implementation and is not
|
||||
affiliated with or endorsed by their authors.
|
||||
|
||||
| Model | Authors | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| MobileSAM (`mobile_sam.pt`) | Chaoning Zhang et al., building on Meta AI's Segment Anything and Microsoft's TinyViT | https://github.com/ChaoningZhang/MobileSAM | Apache-2.0 |
|
||||
| MediaPipe Face Landmarker (`face_landmarker.task`: BlazeFace short-range detector, Face Mesh V2, blendshapes) | Google LLC | https://developers.google.com/edge/mediapipe/solutions/vision/face_landmarker (model cards: https://storage.googleapis.com/mediapipe-assets/MediaPipe%20BlazeFace%20Model%20Card%20(Short%20Range).pdf, https://storage.googleapis.com/mediapipe-assets/Model%20Card%20MediaPipe%20Face%20Mesh%20V2.pdf) | Apache-2.0 |
|
||||
|
||||
## Formats and parameters from other open-source projects
|
||||
|
||||
Written in Rust for EffectCraft; no code is copied. They follow published specifications and
|
||||
configuration from these Apache-2.0 projects:
|
||||
|
||||
- `crates/segment/src/tflite.rs` reads TensorFlow Lite files using the field layout of TensorFlow
|
||||
Lite's `schema.fbs` (The TensorFlow Authors, https://github.com/tensorflow/tensorflow,
|
||||
Apache-2.0).
|
||||
- `crates/segment/src/mediapipe.rs` follows MediaPipe's published face landmarker graph: the SSD
|
||||
anchor layout, detection decoding, crop sizes and the face-mesh point indices it uses (Google
|
||||
LLC, https://github.com/google-ai-edge/mediapipe, Apache-2.0).
|
||||
- `crates/segment/src/mobilesam.rs` follows the MobileSAM, TinyViT and Segment Anything
|
||||
architectures and their Apache-2.0 reference code.
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and are not
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
# EffectCraft — instructions for agents
|
||||
|
||||
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
|
||||
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
|
||||
|
||||
## Start every session here
|
||||
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
|
||||
1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers).
|
||||
2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`.
|
||||
3. Read [`docs/gaps.md`](docs/gaps.md): the honest assessment of where we fall short and the prioritised workstreams. Fidelity, reliability and stability (G1–G3) come before new features.
|
||||
4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
|
||||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
|
|||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
|
||||
```sh
|
||||
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
|
||||
cargo run -p effectcraft # the desktop app, with the demo project
|
||||
cargo test --workspace
|
||||
cargo xtask ci # what every commit must pass
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1134,7 +1134,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1155,7 +1155,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1167,14 +1167,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1190,7 +1190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1198,14 +1198,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-track",
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -1215,7 +1214,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1230,7 +1229,6 @@ dependencies = [
|
|||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
|
|
@ -1248,7 +1246,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1277,7 +1275,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1294,7 +1292,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1302,7 +1300,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1314,24 +1312,21 @@ dependencies = [
|
|||
"effectcraft-render",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"js-sys",
|
||||
"log",
|
||||
"pollster 0.4.0",
|
||||
"wasm-bindgen",
|
||||
"web-time",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1353,7 +1348,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1372,7 +1367,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1384,7 +1379,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1401,7 +1396,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1426,7 +1421,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1437,14 +1432,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1456,7 +1451,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1470,7 +1465,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1482,7 +1477,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1495,14 +1490,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1511,7 +1506,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1533,7 +1528,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1546,18 +1541,9 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1569,7 +1555,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1581,7 +1567,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1589,10 +1575,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1601,7 +1586,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1629,11 +1614,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1747,7 +1732,6 @@ dependencies = [
|
|||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
|
|
@ -2329,15 +2313,6 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-concurrency"
|
||||
version = "7.7.1"
|
||||
|
|
@ -2744,27 +2719,6 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
|
|
@ -3096,16 +3050,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -3223,21 +3167,6 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
|
|
@ -4240,12 +4169,6 @@ dependencies = [
|
|||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
|
|
@ -5264,18 +5187,6 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
|
|
@ -5664,22 +5575,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
|
@ -6423,7 +6318,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -22,7 +22,6 @@ effectcraft-project = { path = "crates/project" }
|
|||
effectcraft-text = { path = "crates/text" }
|
||||
effectcraft-effects = { path = "crates/effects" }
|
||||
effectcraft-track = { path = "crates/track" }
|
||||
effectcraft-segment = { path = "crates/segment" }
|
||||
effectcraft-model = { path = "crates/model" }
|
||||
effectcraft-render = { path = "crates/render" }
|
||||
effectcraft-gpu = { path = "crates/gpu" }
|
||||
|
|
|
|||
51
README.md
51
README.md
|
|
@ -85,9 +85,7 @@ then to go further in a few places where it matters to us:
|
|||
|
||||
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
|
||||
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
|
||||
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
|
||||
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
|
||||
or into the Timeline, between layers and at the time you point to.
|
||||
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect.
|
||||
|
||||
- **Layers of every kind:** solids, shapes, text, footage, nested compositions, nulls,
|
||||
adjustment layers, cameras and lights; parenting, track mattes, all 38 blend modes, motion blur.
|
||||
|
|
@ -99,8 +97,8 @@ or into the Timeline, between layers and at the time you point to.
|
|||
- **Shapes and masks:** shape layers with trim paths, repeaters, round corners, offset, zig zag,
|
||||
twist, wiggle, merge paths and gradient strokes; masks drawn with the pen tool, with modes,
|
||||
feather, expansion and vertex editing in the viewer.
|
||||
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
|
||||
Character and Paragraph panels, and text animators with range selectors.
|
||||
- **Text:** point and paragraph text with real shaping, the Character and Paragraph panels, and
|
||||
text animators with range selectors.
|
||||
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
|
||||
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
|
||||
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
|
||||
|
|
@ -181,19 +179,12 @@ See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control
|
|||
|
||||
## Get started
|
||||
|
||||
Installers for each version are on the [Releases](https://github.com/storytold/effectcraft/releases)
|
||||
page: a universal macOS app; Windows MSIs and portable zips for x64, x86 and ARM64; and Linux
|
||||
AppImage, deb, rpm and tar.gz for x86_64 and aarch64. The Windows ARM64 build runs natively on
|
||||
Windows on ARM, without x64 emulation. CI installs that MSI on Windows 11 ARM64 hardware, checks
|
||||
both programs are ARM64 and runs the command-line tool there, but it doesn't open the app's window
|
||||
or run the test suite natively on ARM64 yet, so please report anything that behaves differently.
|
||||
|
||||
To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/effectcraft
|
||||
cd effectcraft
|
||||
cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project)
|
||||
cargo run --release -p effectcraft # the app, with the demo project open
|
||||
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
|
||||
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
|
||||
```
|
||||
|
|
@ -204,25 +195,13 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
|||
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
|
||||
exists, including all of its effects, but existing is not the same as behaving exactly like After
|
||||
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
|
||||
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
|
||||
|
||||
- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
|
||||
JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
|
||||
- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
|
||||
After Effects automatically, so please report differences.
|
||||
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
|
||||
bugs, which we're fixing first.
|
||||
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
|
||||
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
|
||||
3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
|
||||
|
||||
The [ROADMAP](ROADMAP.md) has the plan and [docs/gaps.md](docs/gaps.md) the full, honest
|
||||
assessment. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
|
||||
send us right now.
|
||||
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
|
||||
done and what comes next is in the [ROADMAP](ROADMAP.md). Still to come: learned models for
|
||||
Roto Brush and face tracking (both use classical methods today), GPU versions of the remaining
|
||||
CPU-only effects, the Advanced 3D Extended Viewer, and shared-memory threads in the web build. The
|
||||
web build ([docs/web.md](docs/web.md)) runs the full app in the browser, with renders, analyses
|
||||
and viewer frames in Web Workers. Projects are saved as `.ecproj`, readable versioned JSON; After
|
||||
Effects `.aep` files cannot be opened.
|
||||
|
||||
## How it's made
|
||||
|
||||
|
|
@ -247,7 +226,7 @@ stand on its own.
|
|||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
|
|
@ -294,7 +273,3 @@ Forks and modified versions must remove them.
|
|||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Feffectcraft&type=date&legend=top-left)
|
||||
|
|
|
|||
50
ROADMAP.md
50
ROADMAP.md
|
|
@ -1,7 +1,5 @@
|
|||
# Roadmap
|
||||
|
||||
- 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English.
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at
|
||||
once.
|
||||
|
|
@ -12,7 +10,7 @@ once.
|
|||
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
|
||||
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
|
||||
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
|
||||
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
|
||||
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
|
||||
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
|
||||
|
|
@ -21,48 +19,20 @@ once.
|
|||
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
|
||||
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done |
|
||||
| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done |
|
||||
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (trained models, open source, downloaded on demand, in a swappable model module: MobileSAM for Roto Brush 2.0 / 3.0, MediaPipe Face Landmarker for face tracking) |
|
||||
| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (Roto Brush and face tracking use classical models; learned models are still to come) |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
## How far from full parity
|
||||
|
||||
There are two honest answers, and they are far apart. The full assessment, with the evidence, is
|
||||
in [docs/gaps.md](docs/gaps.md).
|
||||
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
|
||||
|
||||
- **Breadth: ≈ 99%.** Almost every After Effects feature exists: 89 of 92 catalogued features
|
||||
done and 3 partial, all 306 effects implemented, 280 of them on the GPU
|
||||
([docs/parity.md](docs/parity.md)). This is our own checklist, graded by the agents that built
|
||||
the features.
|
||||
- **Real use: ≈ 30–50% (estimated, not yet measured).** Whether someone who uses After Effects for
|
||||
a living can do client work in EffectCraft. What holds it back:
|
||||
- **Fidelity is unmeasured.** No test compares our output with After Effects itself, and users
|
||||
are still finding behaviour bugs in features marked done.
|
||||
- **After Effects projects can't be opened.** EffectCraft can't read `.aep` / `.aepx` files,
|
||||
and third-party After Effects plug-ins can't run.
|
||||
- **Reliability is uneven across platforms.** Early Linux users hit basic problems: viewer
|
||||
panning, panel docking, drag-and-drop import. A Windows user found the font menus offered only
|
||||
the bundled fonts (fixed 6 October: they list every installed font). Reports of 6 October
|
||||
covered drag and drop between panels, a Render Queue menu that ignored clicks, angle
|
||||
revolutions, and preview with audio and with little video memory (all fixed 7 October).
|
||||
- **AI tools are new.** Roto Brush's trained model (MobileSAM, M13.35) and face tracking's
|
||||
(MediaPipe Face Landmarker, M13.36) are optional downloads, not yet compared with After
|
||||
Effects.
|
||||
|
||||
## Where we're going
|
||||
|
||||
In priority order (details and "done" criteria in [docs/gaps.md](docs/gaps.md)):
|
||||
|
||||
| | Workstream | Why |
|
||||
|---|---|---|
|
||||
| G1 | Measure fidelity against After Effects: a corpus of test projects rendered and sampled in both apps, scored per feature | Turns every estimate into a measurement; a feature that behaves differently is not done |
|
||||
| G2 | Real-user reliability on macOS, Windows and Linux; fix every open user issue | Basic workflows must work everywhere before more features matter |
|
||||
| G3 | Stability: a green gate on the current toolchain, fuzzing of every input | A crash loses people's work |
|
||||
| G4 | Open After Effects projects (`.aep` / `.aepx`, pending an owner decision on clean-room scope); relinking moved footage | The biggest barrier to switching |
|
||||
| G5 | Performance benchmarks at 1080p and 4K on real-world-sized projects | Unknown today |
|
||||
| G6 | Media depth: encoder efficiency, camera and phone formats | Files are larger than from mature encoders; camera formats are unverified |
|
||||
| G7 | Learned models: Roto Brush (M13.35: MobileSAM) and face tracking (M13.36: MediaPipe Face Landmarker), open source, optional downloads, swappable model module; next, measure them | Quality against After Effects is unmeasured |
|
||||
| G8 | Plug-in ecosystem on our WebAssembly plug-in API | After Effects plug-ins can't run here |
|
||||
| G9 | Localisation, accessibility, user documentation | Reach beyond English-speaking power users |
|
||||
- **≈ 99% of After Effects' features, weighted by importance**, counting partial features as half
|
||||
done (≈ 99.8% with per-feature fractions): 89 of 92 features done, 3 partial, none missing; every
|
||||
essential (P0) feature done; all 306 effects implemented in full, 280 of them on the GPU.
|
||||
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
|
||||
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
|
||||
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
|
||||
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
|
|
@ -36,54 +36,10 @@
|
|||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `println!` that never panics: see [`write_line`].
|
||||
macro_rules! say {
|
||||
($($t:tt)*) => {
|
||||
write_line(format_args!($($t)*))
|
||||
};
|
||||
}
|
||||
|
||||
/// `eprintln!` that never panics: a diagnostic that can't be written is dropped.
|
||||
macro_rules! note {
|
||||
($($t:tt)*) => {{
|
||||
let _ = writeln!(std::io::stderr(), $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Why stdout stopped taking output, once it has.
|
||||
static STDOUT_FAILED: std::sync::OnceLock<std::io::ErrorKind> = std::sync::OnceLock::new();
|
||||
|
||||
/// Write a line to stdout. Once that fails, later output is dropped but the command still
|
||||
/// finishes its work (a `run … --save` sequence isn't cut short). A reader that closes stdout
|
||||
/// early (`info --json | head`) has read what it wanted; any other failure is reported and makes
|
||||
/// the exit status 1 ([`stdout_broken`]).
|
||||
fn write_line(text: std::fmt::Arguments) {
|
||||
if STDOUT_FAILED.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let r = {
|
||||
let mut out = std::io::stdout().lock();
|
||||
out.write_fmt(text).and_then(|()| out.write_all(b"\n"))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
let _ = STDOUT_FAILED.set(e.kind());
|
||||
if e.kind() != std::io::ErrorKind::BrokenPipe {
|
||||
note!("effectcraft-cli: cannot write to stdout: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether stdout failed for a reason other than its reader closing it early.
|
||||
fn stdout_broken() -> bool {
|
||||
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
|
|
@ -92,7 +48,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
|||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] [--transparent]
|
||||
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png]
|
||||
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
|
|
@ -156,25 +112,6 @@ const VALUED: &[&str] = &[
|
|||
"--opus-app",
|
||||
];
|
||||
|
||||
/// Options without a value. Any other `--option` is a usage error.
|
||||
const FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--save",
|
||||
"--demo",
|
||||
"--empty",
|
||||
"--gpu",
|
||||
"--list",
|
||||
"--schemas",
|
||||
"--enabled",
|
||||
"--flat",
|
||||
"--transparent",
|
||||
"--work-area",
|
||||
"--queue",
|
||||
"--ops",
|
||||
"--small",
|
||||
"--adv3d",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
pos: Vec<String>,
|
||||
opts: Vec<(String, Option<String>)>,
|
||||
|
|
@ -184,7 +121,7 @@ struct Args {
|
|||
|
||||
impl Args {
|
||||
fn parse(raw: Vec<String>) -> Result<Args, String> {
|
||||
let (mut pos, mut opts, mut unknown) = (vec![], vec![], vec![]);
|
||||
let (mut pos, mut opts) = (vec![], vec![]);
|
||||
let mut it = raw.into_iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a.starts_with("--") && a.len() > 2 {
|
||||
|
|
@ -195,10 +132,6 @@ impl Args {
|
|||
if VALUED.contains(&k.as_str()) {
|
||||
let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?;
|
||||
opts.push((k, Some(v)));
|
||||
} else if !FLAGS.contains(&k.as_str()) {
|
||||
unknown.push(format!("`{k}`"));
|
||||
} else if inline.is_some() {
|
||||
return Err(format!("{k} takes no value"));
|
||||
} else {
|
||||
opts.push((k, None));
|
||||
}
|
||||
|
|
@ -206,12 +139,6 @@ impl Args {
|
|||
pos.push(a);
|
||||
}
|
||||
}
|
||||
// Before anything runs: a misspelt option must not run the command with defaults, or
|
||||
// leave its value behind as a positional (`--saveas x.ecproj` opening x.ecproj).
|
||||
if !unknown.is_empty() {
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
return Err(format!("unknown option{s} {}", unknown.join(", ")));
|
||||
}
|
||||
let mut a = Args { pos, opts, project: None };
|
||||
a.project = match a.opt("--project") {
|
||||
Some(p) => Some(p.to_string()),
|
||||
|
|
@ -246,11 +173,11 @@ fn value_arg(s: &str) -> Value {
|
|||
fn main() {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
|
||||
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
|
||||
say!("{USAGE}");
|
||||
println!("{USAGE}");
|
||||
std::process::exit(if raw.is_empty() { 2 } else { 0 });
|
||||
}
|
||||
let mut args = match Args::parse(raw) {
|
||||
|
|
@ -263,21 +190,20 @@ fn main() {
|
|||
}
|
||||
let cmd = args.pos.remove(0);
|
||||
match run(&cmd, &args, json_out) {
|
||||
Ok(()) if stdout_broken() => std::process::exit(1),
|
||||
Ok(()) => {}
|
||||
Err(Failure::Usage(e)) => fail_usage(&e),
|
||||
Err(Failure::Error(e)) => {
|
||||
if json_out {
|
||||
say!("{}", json!({"error": e}));
|
||||
println!("{}", json!({"error": e}));
|
||||
}
|
||||
note!("effectcraft-cli {cmd}: {e}");
|
||||
eprintln!("effectcraft-cli {cmd}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_usage(e: &str) -> ! {
|
||||
note!("effectcraft-cli: {e}\n{USAGE}");
|
||||
eprintln!("effectcraft-cli: {e}\n{USAGE}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
|
|
@ -366,13 +292,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
emit(&o, true);
|
||||
} else {
|
||||
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
say!("{out}");
|
||||
println!("{out}");
|
||||
}
|
||||
if !r["result"].is_null() && r["error"].is_null() {
|
||||
say!("{}", r["result"]);
|
||||
println!("{}", r["result"]);
|
||||
}
|
||||
if let Some(p) = saved {
|
||||
note!("saved {p}");
|
||||
eprintln!("saved {p}");
|
||||
}
|
||||
}
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
|
|
@ -393,9 +319,9 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
/// Print a result: compact JSON with `--json`, else pretty JSON.
|
||||
fn emit(v: &Value, json_out: bool) {
|
||||
if json_out {
|
||||
say!("{v}");
|
||||
println!("{v}");
|
||||
} else {
|
||||
say!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -418,19 +344,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
say!(
|
||||
println!(
|
||||
"EffectCraft {} ({}), {} commands, {} effects",
|
||||
info["version"].as_str().unwrap_or(""),
|
||||
info["mode"].as_str().unwrap_or(""),
|
||||
info["commands"],
|
||||
info["effects"]
|
||||
);
|
||||
say!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
|
||||
println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
|
||||
for i in p["items"].as_array().into_iter().flatten() {
|
||||
say!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
|
||||
println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
if let Some(c) = comp {
|
||||
say!(
|
||||
println!(
|
||||
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
|
||||
c["id"],
|
||||
c["name"].as_str().unwrap_or(""),
|
||||
|
|
@ -440,7 +366,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
c["duration"]
|
||||
);
|
||||
for l in c["layers"].as_array().into_iter().flatten() {
|
||||
say!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
|
||||
println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -496,7 +422,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
say!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
|
||||
println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
|
||||
for p in v["properties"].as_array().into_iter().flatten() {
|
||||
let mut extra = String::new();
|
||||
if let Some(k) = p.get("keys") {
|
||||
|
|
@ -505,7 +431,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -548,23 +474,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
let out = args.opt("--out").unwrap_or("frame.png").to_string();
|
||||
let t0 = std::time::Instant::now();
|
||||
let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
|
||||
let f = b.render(comp.as_ref(), time, max_side)?;
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
|
||||
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
|
||||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
note!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
|
||||
eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let b = backend(args, false)?;
|
||||
// A client that closes its end of stdout has ended the session.
|
||||
match McpServer::new(b).serve_stdio() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()),
|
||||
_ => {}
|
||||
}
|
||||
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
|
||||
}
|
||||
other => return usage_err(&format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
|
|
@ -655,7 +577,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
|
||||
}
|
||||
let r = s.execute("renderQueue.add", p).map_err(err)?;
|
||||
note!(
|
||||
eprintln!(
|
||||
"{} → {} ({}×{}, {} frames, {})",
|
||||
r["compName"].as_str().unwrap_or("?"),
|
||||
r["outputPath"].as_str().unwrap_or("?"),
|
||||
|
|
@ -671,8 +593,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
std::io::stderr(),
|
||||
eprint!(
|
||||
"
|
||||
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
|
||||
(p.items_done + 1).min(p.items_total),
|
||||
|
|
@ -686,7 +607,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
s.poll_render();
|
||||
if tty {
|
||||
note!();
|
||||
eprintln!();
|
||||
}
|
||||
let mut results = vec![];
|
||||
let mut failed = vec![];
|
||||
|
|
@ -694,7 +615,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
|
||||
match &it.status {
|
||||
RenderStatus::Done => {
|
||||
note!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
|
||||
eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
|
||||
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
|
||||
}
|
||||
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
|
||||
|
|
@ -719,7 +640,7 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
emit(&v, true);
|
||||
} else {
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
say!(
|
||||
println!(
|
||||
"{:<36} {:<36} {:<16} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
|
|
@ -787,7 +708,7 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
|
|||
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
|
||||
let mut s = effectcraft_host::session();
|
||||
if let Err(e) = s.execute("file.open", json!({"path": path})) {
|
||||
note!("bench --ops: {e}");
|
||||
eprintln!("bench --ops: {e}");
|
||||
}
|
||||
s
|
||||
}));
|
||||
|
|
@ -795,9 +716,9 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
|
|||
if args.flag("--json") {
|
||||
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<Vec<_>>()}), true);
|
||||
} else {
|
||||
say!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
|
||||
println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
|
||||
for x in &m {
|
||||
say!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
|
||||
println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -867,7 +788,7 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
}
|
||||
}
|
||||
}
|
||||
note!(
|
||||
eprintln!(
|
||||
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
|
||||
opts.scale,
|
||||
min(&totals),
|
||||
|
|
@ -875,9 +796,9 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
min(&cpus)
|
||||
);
|
||||
for (d, name, p, c, fx) in per_layer {
|
||||
note!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
|
||||
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
|
||||
for (id, v) in fx {
|
||||
note!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
|
||||
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -898,7 +819,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
|
||||
let mean = v.iter().sum::<f64>() / n as f64;
|
||||
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
|
||||
note!(
|
||||
eprintln!(
|
||||
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
|
||||
opts.scale,
|
||||
median(&mut v),
|
||||
|
|
@ -906,7 +827,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
);
|
||||
}
|
||||
let st = cache.stats();
|
||||
note!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
|
||||
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
|
||||
}
|
||||
|
||||
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
|
||||
|
|
@ -1087,21 +1008,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
|||
}
|
||||
};
|
||||
let project = &project;
|
||||
note!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
|
||||
note!(
|
||||
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
|
||||
eprintln!(
|
||||
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}",
|
||||
"comp",
|
||||
"res",
|
||||
"size",
|
||||
"cpu cold",
|
||||
"gpu cold",
|
||||
"cpu warm",
|
||||
"gpu warm",
|
||||
"gpu view",
|
||||
"speedup",
|
||||
"auto",
|
||||
"gpu≠cpu",
|
||||
"up/dn MB"
|
||||
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB"
|
||||
);
|
||||
for cid in comps {
|
||||
let Some(comp) = project.comp(cid) else { continue };
|
||||
|
|
@ -1167,46 +1077,10 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
|||
.count();
|
||||
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
|
||||
let speedup = cpu_warm / gpu_warm.max(1e-9);
|
||||
note!(
|
||||
eprintln!(
|
||||
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Args, String> {
|
||||
Args::parse(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
/// Unknown options are usage errors naming them, not ignored (#168).
|
||||
#[test]
|
||||
fn unknown_options_are_refused() {
|
||||
let err = |args: &[&str]| parse(args).err().unwrap();
|
||||
assert_eq!(err(&["exec", "comp.new", "--bogus", "--empty", "--saveas", "z.ecproj"]), "unknown options `--bogus`, `--saveas`");
|
||||
assert_eq!(err(&["render", "--bogus=1"]), "unknown option `--bogus`");
|
||||
assert_eq!(err(&["info", "--json=yes"]), "--json takes no value");
|
||||
let a = parse(&["render", "t.ecproj", "--comp=Main", "--work-area", "--out", "x.mp4", "--json"]).unwrap();
|
||||
assert_eq!((a.project.as_deref(), a.opt("--comp"), a.opt("--out")), (Some("t.ecproj"), Some("Main"), Some("x.mp4")));
|
||||
assert!(a.flag("--work-area") && a.flag("--json"));
|
||||
}
|
||||
|
||||
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
|
||||
#[test]
|
||||
fn every_option_read_is_declared() {
|
||||
let code = include_str!("main.rs").split("#[cfg(test)]").next().unwrap();
|
||||
for rest in code.split("\"--").skip(1) {
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
|
||||
let opt = format!("--{name}");
|
||||
// `--help` and `--version` are handled before parsing.
|
||||
if name.is_empty() || !rest[name.len()..].starts_with('"') || ["--help", "--version"].contains(&opt.as_str()) {
|
||||
continue;
|
||||
}
|
||||
assert!(VALUED.contains(&opt.as_str()) || FLAGS.contains(&opt.as_str()), "{opt} is read but not declared");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,60 +130,6 @@ fn errors_are_json() {
|
|||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// An unknown option is a usage error (exit 2) naming it, before anything runs: the command
|
||||
/// doesn't run with defaults, and a misspelt option's value isn't opened as the project (#168).
|
||||
#[test]
|
||||
fn unknown_options_are_usage_errors() {
|
||||
let png = tmp("bogus.png");
|
||||
let o = png.to_str().unwrap();
|
||||
for (args, named) in [
|
||||
(&["exec", "comp.new", "--bogus", "--empty", "--json"][..], "`--bogus`"),
|
||||
(&["render-frame", "--bogusflag", "--out", o, "--json"], "`--bogusflag`"),
|
||||
(&["exec", "comp.new", "--empty", "--saveas", "z.ecproj", "--json"], "`--saveas`"),
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
|
||||
assert!(err.contains(&format!("unknown option {named}")), "{args:?}: {err}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}: nothing ran");
|
||||
}
|
||||
assert!(!png.exists(), "render-frame didn't render");
|
||||
// `--transparent` (silently ignored before) keeps the frame's alpha.
|
||||
let proj = tmp("transparent.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
ok_json(&["run", "comp.new", r#"{"name":"T","width":32,"height":32}"#, "layer.newSolid", r#"{"width":8,"height":8}"#, "--empty", "--save-as", p]);
|
||||
for (flag, alpha) in [(None, 255), (Some("--transparent"), 0)] {
|
||||
ok_json(&[&["render-frame", p, "--out", o][..], flag.as_slice()].concat());
|
||||
assert_eq!(image::open(&png).unwrap().to_rgba8().get_pixel(0, 0)[3], alpha, "{flag:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
|
||||
/// still does its work and exits 0, without a panic (#167).
|
||||
#[test]
|
||||
fn a_closed_stdout_is_not_a_crash() {
|
||||
let proj = tmp("closed-stdout.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
for args in [&["info", "--json"][..], &["exec", "--list"], &["run", "comp.new", r#"{"name":"Piped"}"#, "project.summary", "--empty", "--save-as", p]] {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {err}");
|
||||
assert!(!err.contains("panicked"), "{args:?}: {err}");
|
||||
}
|
||||
assert!(std::fs::read_to_string(&proj).unwrap().contains("Piped"), "the sequence ran to the end and saved");
|
||||
// The MCP server ends quietly when its client closes stdout.
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}));
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
|
@ -239,11 +185,4 @@ fn script_file_and_eval() {
|
|||
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(v["error"]["line"], json!(2));
|
||||
// A compiled .jsxbin script is reported as unsupported, not as a SyntaxError (#176).
|
||||
let bin_script = tmp("compiled.jsxbin");
|
||||
std::fs::write(&bin_script, "@JSXBIN@ES@2.0@MyBbyBn0ABJAnAEjzFjBjMjFjSjUBfRBFeFjIjFjMjMjPff0DzACByB\n").unwrap();
|
||||
let out = bin().args(["script", bin_script.to_str().unwrap()]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,22 +204,6 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
// The page owns this shared presentation device; worker devices retain their
|
||||
// separate owned-device handlers. No browser error-scope future is blocked on.
|
||||
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let errors = gpu_failures.clone();
|
||||
rs.device.on_uncaptured_error(Arc::new(move |error| {
|
||||
let message = format!("uncaptured GPU error: {error}");
|
||||
if errors.report(&message, false) {
|
||||
log::error!("{message}");
|
||||
}
|
||||
}));
|
||||
let lost = gpu_failures.clone();
|
||||
rs.device.set_device_lost_callback(move |reason, message| {
|
||||
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
|
||||
});
|
||||
}
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
let recovery = session.begin_recovery();
|
||||
|
|
@ -231,7 +215,6 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
|||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = home;
|
||||
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
|
||||
app.offer_recovery(r);
|
||||
|
|
|
|||
|
|
@ -477,8 +477,8 @@ mod tests {
|
|||
a.execute("comp.new", json!({"name": "C"})).unwrap();
|
||||
// Untitled: the auto-save lands in the store's file table.
|
||||
let p = a.autosave_now().unwrap();
|
||||
assert_eq!(Path::new(&p), Path::new("/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj"));
|
||||
assert!(s.file(&path_str(Path::new(&p))).is_some());
|
||||
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
|
||||
assert!(s.file(&p).is_some());
|
||||
// The tab closes without a clean exit: the next visit offers the auto-save.
|
||||
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
b.load_settings();
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@ effectcraft-engine = { workspace = true }
|
|||
effectcraft-host = { workspace = true }
|
||||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
//! Desktop integration for the AppImage. Wayland compositors (GNOME, KDE…) show a window's icon
|
||||
//! from the desktop entry named by its app ID (`ai.storyteller.effectcraft.desktop`). The .deb,
|
||||
//! .rpm and Flatpak packages install one; an AppImage installs nothing, so its window got the
|
||||
//! generic icon. Started from an AppImage (`$APPIMAGE`), the app adds a hidden entry
|
||||
//! (`NoDisplay=true`: no menu item) pointing at it and its icon under the user's data folder
|
||||
//! (`$XDG_DATA_HOME`, else `~/.local/share`). Nothing is written when another entry already
|
||||
//! provides the ID (an installed package, or the user's own), and an entry it wrote is kept up to
|
||||
//! date when the AppImage moves. Failures are only logged: the icon is cosmetic.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const APP_ID: &str = "ai.storyteller.effectcraft";
|
||||
|
||||
/// Marks entries this module wrote (it never touches others).
|
||||
const MARKER: &str = "X-EffectCraft-AppImage=true";
|
||||
|
||||
/// A path as one `Exec` argument (desktop entry spec: quoted, with `"`, `` ` ``, `$` and `\`
|
||||
/// escaped; `%` doubled so it isn't a field code).
|
||||
pub fn exec_arg(path: &str) -> String {
|
||||
let mut s = String::with_capacity(path.len() + 2);
|
||||
s.push('"');
|
||||
for c in path.chars() {
|
||||
// The quoting escape is a backslash, which the key file's string syntax escapes again:
|
||||
// `"` is written `\\"`, a backslash `\\\\`.
|
||||
match c {
|
||||
'"' | '`' | '$' => {
|
||||
s.push_str("\\\\");
|
||||
s.push(c);
|
||||
}
|
||||
'\\' => s.push_str("\\\\\\\\"),
|
||||
'%' => s.push_str("%%"),
|
||||
'\n' | '\r' => {}
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
s.push('"');
|
||||
s
|
||||
}
|
||||
|
||||
/// The hidden desktop entry for the AppImage at `appimage`.
|
||||
pub fn entry(appimage: &str) -> String {
|
||||
format!(
|
||||
"[Desktop Entry]\nType=Application\nName=EffectCraft\nComment=Motion graphics and visual effects compositor\nExec={} %F\nIcon={APP_ID}\nTerminal=false\nStartupWMClass={APP_ID}\nNoDisplay=true\n{MARKER}\n",
|
||||
exec_arg(appimage)
|
||||
)
|
||||
}
|
||||
|
||||
/// Write the entry and `icon` (a 256 px PNG) under `data_home`, unless one of `data_dirs` (or
|
||||
/// `data_home` itself, with an entry we didn't write) already has the ID's entry. Returns whether
|
||||
/// anything was written.
|
||||
pub fn integrate(appimage: &str, data_home: &Path, data_dirs: &[PathBuf], icon: &[u8]) -> std::io::Result<bool> {
|
||||
let name = format!("{APP_ID}.desktop");
|
||||
if data_dirs.iter().any(|d| d.join("applications").join(&name).exists()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let path = data_home.join("applications").join(&name);
|
||||
let text = entry(appimage);
|
||||
let icon_path = data_home.join("icons/hicolor/256x256/apps").join(format!("{APP_ID}.png"));
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(old) if !old.contains(MARKER) => return Ok(false),
|
||||
Ok(old) if old == text && icon_path.exists() => return Ok(false),
|
||||
_ => {}
|
||||
}
|
||||
write_atomic(&icon_path, icon)?;
|
||||
write_atomic(&path, text.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("part");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
/// [`integrate`] for this process: when started from an AppImage, with the XDG data folders of
|
||||
/// the environment.
|
||||
pub fn integrate_from_env(icon: &[u8]) {
|
||||
let Some(appimage) = std::env::var_os("APPIMAGE").map(|a| a.to_string_lossy().into_owned()) else { return };
|
||||
let env_path = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let Some(data_home) = env_path("XDG_DATA_HOME").or_else(|| env_path("HOME").map(|h| h.join(".local/share"))) else { return };
|
||||
let dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
let data_dirs: Vec<PathBuf> = dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
match integrate(&appimage, &data_home, &data_dirs, icon) {
|
||||
Ok(true) => log::info!("AppImage: desktop entry and icon written to {}", data_home.display()),
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!("AppImage: could not write the desktop entry: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exec_arguments_are_quoted_and_escaped() {
|
||||
assert_eq!(exec_arg("/home/a/EffectCraft.AppImage"), "\"/home/a/EffectCraft.AppImage\"");
|
||||
assert_eq!(exec_arg("/a b/$x\"y`z\\w%"), "\"/a b/\\\\$x\\\\\"y\\\\`z\\\\\\\\w%%\"");
|
||||
let e = entry("/opt/EffectCraft.AppImage");
|
||||
assert!(e.contains("Exec=\"/opt/EffectCraft.AppImage\" %F\n") && e.contains("NoDisplay=true\n") && e.contains("Icon=ai.storyteller.effectcraft\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_once_follows_moves_and_leaves_other_entries_alone() {
|
||||
let root = std::env::temp_dir().join(format!("ec-appimage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (home, system) = (root.join("home"), root.join("system"));
|
||||
let entry_path = home.join("applications/ai.storyteller.effectcraft.desktop");
|
||||
let icon_path = home.join("icons/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
||||
// First start: entry and icon written; again: nothing to do.
|
||||
assert!(integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
|
||||
assert_eq!(std::fs::read(&icon_path).unwrap(), b"png");
|
||||
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/a/EffectCraft.AppImage\""));
|
||||
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
|
||||
// The AppImage moved: the entry follows it.
|
||||
assert!(integrate("/b/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
|
||||
assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/b/EffectCraft.AppImage\""));
|
||||
// The user's own entry is never replaced.
|
||||
std::fs::write(&entry_path, "[Desktop Entry]\nName=Mine\n").unwrap();
|
||||
assert!(!integrate("/c/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), "[Desktop Entry]\nName=Mine\n");
|
||||
// An installed package provides the entry: nothing is written.
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(system.join("applications")).unwrap();
|
||||
std::fs::write(system.join("applications/ai.storyteller.effectcraft.desktop"), "x").unwrap();
|
||||
assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap());
|
||||
assert!(!entry_path.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ fn run<T: SizedSample + FromSample<f32>>(
|
|||
let frames = out.len() / ch.max(1);
|
||||
stereo.resize(frames * 2, 0.0);
|
||||
feed.pull(&mut stereo);
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.as_chunks::<2>().0.iter()) {
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.chunks_exact(2)) {
|
||||
for (c, o) in f.iter_mut().enumerate() {
|
||||
// Mono devices, or left and right mapped to the same channel, get the mix.
|
||||
let v = if ch == 1 || (c == left && c == right) {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,13 @@
|
|||
//! Localhost JSON-lines control server (one request per line, one reply per line). Loopback only.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use effectcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
|
|
@ -32,243 +18,44 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
|||
}
|
||||
};
|
||||
eprintln!("effectcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:9877\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,11 @@
|
|||
//!
|
||||
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
|
||||
//!
|
||||
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
|
||||
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
|
||||
//!
|
||||
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! see `effectcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod appimage;
|
||||
mod audio_out;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -23,60 +17,36 @@ use serde_json::json;
|
|||
|
||||
/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
|
||||
/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
|
||||
/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
||||
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let png: &[u8] = ICON_PNG;
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The command line.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Args {
|
||||
control_port: Option<u16>,
|
||||
files: Vec<String>,
|
||||
/// Open the demo project when no project is given (`--demo`).
|
||||
demo: bool,
|
||||
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
|
||||
home: Option<bool>,
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name; `None` for `--version`. `env_port` is
|
||||
/// `EFFECTCRAFT_CONTROL_PORT`.
|
||||
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Option<Args> {
|
||||
let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() };
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => out.control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => out.demo = true,
|
||||
"--empty" => out.demo = false,
|
||||
"--home" => out.home = Some(true),
|
||||
"--version" => return None,
|
||||
_ => out.files.push(a),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
|
||||
effectcraft_engine::logging::install();
|
||||
effectcraft_engine::logging::install_panic_hook();
|
||||
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
};
|
||||
// The font menus list the installed fonts: read their names while the window opens.
|
||||
effectcraft_engine::text::fonts::scan_system_in_background();
|
||||
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
|
||||
#[cfg(target_os = "linux")]
|
||||
appimage::integrate_from_env(ICON_PNG);
|
||||
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut demo = true;
|
||||
let mut home: Option<bool> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"--empty" => demo = false,
|
||||
"--home" => home = Some(true),
|
||||
"--version" => {
|
||||
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("EffectCraft")
|
||||
|
|
@ -87,32 +57,14 @@ fn main() -> eframe::Result {
|
|||
.with_active(control_port.is_none())
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
|
||||
.with_app_id(appimage::APP_ID),
|
||||
.with_app_id("ai.storyteller.effectcraft"),
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
wgpu_options: wgpu_options(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = eframe::run_native(
|
||||
eframe::run_native(
|
||||
"EffectCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// This executable owns the shared device's handlers. Install them before
|
||||
// EffectCraft's compositor pipelines are built, without changing handlers from
|
||||
// inside Gpu::new. eframe has already constructed its presentation renderer here.
|
||||
let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let errors = gpu_failures.clone();
|
||||
rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
|
||||
let message = format!("uncaptured GPU error: {error}");
|
||||
if errors.report(&message, false) {
|
||||
log::error!("{message}");
|
||||
}
|
||||
}));
|
||||
let lost = gpu_failures.clone();
|
||||
rs.device.set_device_lost_callback(move |reason, message| {
|
||||
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
|
||||
});
|
||||
}
|
||||
let mut session = effectcraft_host::session();
|
||||
// Lazy open and non-blocking auto-save: footage is checked and auto-saves are
|
||||
// written on background threads (M13.14).
|
||||
|
|
@ -120,7 +72,7 @@ fn main() -> eframe::Result {
|
|||
session.autosave.background = true;
|
||||
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = effectcraft_host::config_dir() {
|
||||
if let Some(dir) = config_dir() {
|
||||
// WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
|
||||
let plugins = dir.join("Plug-ins");
|
||||
if plugins.is_dir() {
|
||||
|
|
@ -146,7 +98,6 @@ fn main() -> eframe::Result {
|
|||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = show_home;
|
||||
if let Some(r) = recovery {
|
||||
app.offer_recovery(r);
|
||||
|
|
@ -164,14 +115,7 @@ fn main() -> eframe::Result {
|
|||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
|
||||
// A default with a folder that exists opens the dialog there.
|
||||
let path = std::path::Path::new(name);
|
||||
let mut d = rfd::FileDialog::new().add_filter(ext, &[ext]);
|
||||
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
|
||||
d = d.set_directory(dir);
|
||||
}
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
rfd::FileDialog::new().add_filter(ext, &[ext]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
|
@ -189,50 +133,7 @@ fn main() -> eframe::Result {
|
|||
app,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
if let Err(e) = &result {
|
||||
startup_failed(e);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
|
||||
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
|
||||
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
|
||||
/// the CPU when the device can't run it.
|
||||
fn wgpu_options() -> eframe::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::WgpuSetup;
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
|
||||
label: Some("EffectCraft device"),
|
||||
required_limits: device_limits(adapter.limits()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
|
||||
/// capped at 16384 px like the headless renderer's device.
|
||||
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
|
||||
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
|
||||
}
|
||||
|
||||
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
|
||||
/// silently, since release builds on Windows have no console.
|
||||
fn startup_failed(e: &eframe::Error) {
|
||||
let message = format!(
|
||||
"EffectCraft couldn't start its window: {e}\n\nUpdating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
|
||||
);
|
||||
log::error!("{message}");
|
||||
eprintln!("effectcraft: {message}");
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("EffectCraft")
|
||||
.set_description(message)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
)
|
||||
}
|
||||
|
||||
/// The desktop app: the egui app plus, on macOS, the system menu bar.
|
||||
|
|
@ -262,6 +163,24 @@ impl eframe::App for Desktop {
|
|||
}
|
||||
}
|
||||
|
||||
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
|
||||
/// `~/Library/Application Support/EffectCraft` (macOS), `%APPDATA%\EffectCraft` (Windows),
|
||||
/// `$XDG_CONFIG_HOME/effectcraft` or `~/.config/effectcraft` (Linux and others).
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
use std::path::PathBuf;
|
||||
if let Some(d) = std::env::var_os("EFFECTCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
}
|
||||
let home = || std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home().map(|h| h.join("Library/Application Support/EffectCraft"));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("EffectCraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config"))).map(|c| c.join("effectcraft"))
|
||||
}
|
||||
|
||||
/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
|
||||
/// focus stays where it is (macOS; winit activates ignoring other apps by default).
|
||||
fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
|
|
@ -289,37 +208,3 @@ fn disable_app_nap() {
|
|||
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Args, parse_args};
|
||||
|
||||
fn parse(args: &[&str]) -> Option<Args> {
|
||||
parse_args(args.iter().map(|a| a.to_string()), None)
|
||||
}
|
||||
|
||||
/// The window's device never asks for more than the adapter offers (#198).
|
||||
#[test]
|
||||
fn device_limits_fit_the_adapter() {
|
||||
use eframe::wgpu::Limits;
|
||||
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
|
||||
let l = super::device_limits(adapter.clone());
|
||||
assert!(l.check_limits(&adapter), "{l:?}");
|
||||
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
|
||||
}
|
||||
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
|
||||
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
|
||||
}
|
||||
|
||||
/// Launching without a project opens an empty project, not the demo (#204).
|
||||
#[test]
|
||||
fn no_arguments_start_an_empty_project() {
|
||||
assert_eq!(parse(&[]), Some(Args::default()));
|
||||
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
|
||||
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
|
||||
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
|
||||
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
|
||||
assert_eq!(parse(&["--version"]), None);
|
||||
assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ impl NativeBar {
|
|||
let sub = Submenu::new(label, true);
|
||||
self.fill(&sub, children);
|
||||
match label.as_str() {
|
||||
"Window" | "ウィンドウ" => sub.set_as_windows_menu_for_nsapp(),
|
||||
"Help" | "ヘルプ" => sub.set_as_help_menu_for_nsapp(),
|
||||
"Window" => sub.set_as_windows_menu_for_nsapp(),
|
||||
"Help" => sub.set_as_help_menu_for_nsapp(),
|
||||
_ => {}
|
||||
}
|
||||
if let Err(e) = menu.append(&sub) {
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ pub fn encode_png(w: u32, h: u32, rgba: Vec<u8>, max_side: u32) -> Result<Vec<u8
|
|||
let mut out = Vec::new();
|
||||
let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive);
|
||||
let raw = img.into_raw();
|
||||
let r = if raw.as_chunks::<4>().0.iter().all(|p| p[3] == 255) {
|
||||
let rgb: Vec<u8> = raw.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let r = if raw.chunks_exact(4).all(|p| p[3] == 255) {
|
||||
let rgb: Vec<u8> = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8)
|
||||
} else {
|
||||
enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8)
|
||||
|
|
|
|||
|
|
@ -177,28 +177,6 @@ fn headless_workflow() {
|
|||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An expression with a syntax error is kept but disabled; set_property and get_property report
|
||||
/// its error, and `evaluated` is the static value that renders (#163).
|
||||
#[test]
|
||||
fn expression_syntax_errors_reach_the_reply() {
|
||||
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000", "width": 32, "height": 8}}));
|
||||
let at = |expr: &str| json!({"layer": "S", "path": "transform/rotation", "expression": expr});
|
||||
call_json(&mut s, "set_property", json!({"layer": "S", "path": "transform/rotation", "value": 30}));
|
||||
let set = call_json(&mut s, "set_property", at("thisIsBroken("));
|
||||
let got = call_json(&mut s, "get_property", json!({"layer": "S", "path": "transform/rotation"}));
|
||||
for r in [&set, &got] {
|
||||
assert_eq!((&r["expression"], &r["evaluated"]), (&json!("thisIsBroken("), &json!(30.0)), "{r}");
|
||||
assert!(r["expressionError"].as_str().unwrap().contains("SyntaxError"), "{r}");
|
||||
}
|
||||
// A runtime error and a valid expression report as before.
|
||||
let r = call_json(&mut s, "set_property", at("nope*2"));
|
||||
assert!(r["expressionError"].as_str().unwrap().contains("nope is not defined"), "{r}");
|
||||
let r = call_json(&mut s, "set_property", at("60"));
|
||||
assert_eq!((&r["evaluated"], r.get("expressionError")), (&json!(60.0), None), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_loop() {
|
||||
let mut s = server();
|
||||
|
|
@ -415,95 +393,3 @@ fn high_level_tools_add_effect_and_report_state() {
|
|||
let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
|
||||
}
|
||||
|
||||
/// add_effect is one undo step, and a call that fails leaves the layer and the history as they
|
||||
/// were (#175).
|
||||
#[test]
|
||||
fn add_effect_is_one_undo_step_and_all_or_nothing() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000"}}));
|
||||
let undo = |s: &mut McpServer| call_json(s, "get_project", json!({}))["undo"].as_array().unwrap().clone();
|
||||
let effects = |s: &mut McpServer| {
|
||||
let l = call_json(s, "get_layer", json!({"layer": "S", "flat": true}));
|
||||
l["properties"].as_array().unwrap().iter().filter(|p| p["path"].as_str().unwrap().starts_with("effects/")).count()
|
||||
};
|
||||
let before = undo(&mut s);
|
||||
let (c, err) = call(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdgePixels": true}}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("effects/#1/repeatEdgePixels"), "{c:?}");
|
||||
assert_eq!((undo(&mut s), effects(&mut s)), (before.clone(), 0), "nothing applied");
|
||||
// The corrected retry lands at #1, in one undo step named like effect.apply's.
|
||||
let r = call_json(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdge": true}}));
|
||||
assert_eq!((&r["path"], &r["set"]), (&json!("effects/#1"), &json!(["effects/#1/blurriness", "effects/#1/repeatEdge"])), "{r}");
|
||||
let after = undo(&mut s);
|
||||
assert_eq!(after.len(), before.len() + 1, "{after:?}");
|
||||
assert_eq!(after.last(), Some(&json!("Apply Gaussian Blur")));
|
||||
call_json(&mut s, "undo", json!({}));
|
||||
assert_eq!((undo(&mut s), effects(&mut s)), (before, 0), "one undo removes the effect and its values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_fonts_lists_families_with_styles() {
|
||||
let mut s = server();
|
||||
let all = call_json(&mut s, "list_fonts", json!({}));
|
||||
let fams = all["families"].as_array().unwrap();
|
||||
assert!(fams.iter().any(|f| f["family"] == "Inter" && f["origin"] == "bundled"), "{all}");
|
||||
// The query filters by substring: the bundled JetBrains Mono, and any installed family whose
|
||||
// name contains it (JetBrainsMono Nerd Font…) (#149).
|
||||
let one = call_json(&mut s, "list_fonts", json!({"query": "jetbrains"}));
|
||||
let matched = one["families"].as_array().unwrap();
|
||||
assert_eq!(one["count"].as_u64(), Some(matched.len() as u64), "{one}");
|
||||
assert!(matched.iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("jetbrains")), "{one}");
|
||||
assert!(matched.len() < fams.len(), "the query filters: {one}");
|
||||
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
|
||||
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
|
||||
}
|
||||
|
||||
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
|
||||
/// licence, chosen, refused when unknown.
|
||||
#[test]
|
||||
fn trained_models_over_mcp() {
|
||||
let mut s = server();
|
||||
for (prefix, id) in [("roto", "mobilesam"), ("face", "mediapipe-face")] {
|
||||
let list = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.models"), "params": {}}));
|
||||
let models = list["models"].as_array().unwrap();
|
||||
assert!(models.iter().any(|m| m["id"] == id && m["licence"] == "Apache-2.0" && m["authors"].is_string() && m["installed"] == false), "{list}");
|
||||
let r = call_json(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": id}}));
|
||||
assert_eq!(r["model"], id);
|
||||
let (c, err) = call(&mut s, "execute_command", json!({"command": format!("{prefix}.model.select"), "params": {"id": "nope"}}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown model"), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/agents.md's Tools table lists every tool, and an entry that lists arguments lists every
|
||||
/// argument the tool's input schema accepts (#137).
|
||||
#[test]
|
||||
fn agents_doc_tools_table_is_current() {
|
||||
// (Checkouts may have CRLF line endings.)
|
||||
let doc = include_str!("../../../docs/agents.md").replace("\r\n", "\n");
|
||||
let table: String = doc.split("### Tools").nth(1).and_then(|t| t.split("\n\n").nth(1)).unwrap_or_default().to_string();
|
||||
assert!(table.starts_with("| Tool |"), "no Tools table");
|
||||
let mut missing = vec![];
|
||||
for t in crate::tools::catalogue() {
|
||||
// `name` (no arguments) or `name {a, b?, …}`.
|
||||
let Some(at) = table.find(&format!("`{}", t.name)).filter(|i| {
|
||||
let rest = &table[i + 1 + t.name.len()..];
|
||||
rest.starts_with('`') || rest.starts_with(" {")
|
||||
}) else {
|
||||
missing.push(format!("{}: no entry", t.name));
|
||||
continue;
|
||||
};
|
||||
let rest = &table[at + 1 + t.name.len()..];
|
||||
if !rest.starts_with(" {") {
|
||||
continue;
|
||||
}
|
||||
let args = rest.split("}`").next().unwrap_or_default();
|
||||
let schema = t.input_schema();
|
||||
for prop in schema["properties"].as_object().into_iter().flat_map(|p| p.keys()) {
|
||||
if !args.split(|c: char| !(c.is_alphanumeric() || c == '_')).any(|w| w == prop) {
|
||||
missing.push(format!("{}: argument `{prop}`", t.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "docs/agents.md's Tools table is missing: {missing:#?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,12 +150,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
|
||||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
|
||||
/// so one undo step, and a failing value leaves nothing applied.
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
|
||||
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
|
||||
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
|
|
@ -163,22 +158,16 @@ fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
let index = count(&before) + 1;
|
||||
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
|
||||
p["layers"] = json!([layer]);
|
||||
let mut steps = vec![json!({"command": "effect.apply", "params": p})];
|
||||
b.exec("effect.apply", p)?;
|
||||
let prefix = format!("effects/#{index}");
|
||||
let mut set = vec![];
|
||||
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
|
||||
for (k, v) in vals {
|
||||
let path = format!("{prefix}/{k}");
|
||||
steps.push(
|
||||
json!({"command": "prop.set", "params": obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))])}),
|
||||
);
|
||||
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
|
||||
set.push(path);
|
||||
}
|
||||
}
|
||||
// The undo step is named like effect.apply's own ("Apply Gaussian Blur").
|
||||
let name = effect.as_str().map(|e| effectcraft_engine::effects::lookup(e).map_or(e, |s| s.name));
|
||||
let label = name.map(|n| json!(format!("Apply {n}")));
|
||||
b.exec("engine.batch", obj(&[("steps", Some(&json!(steps))), ("label", label.as_ref())]))?;
|
||||
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
|
||||
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
|
||||
let mut params = vec![];
|
||||
|
|
@ -597,7 +586,7 @@ static TOOLS: &[ToolDef] = &[
|
|||
},
|
||||
ToolDef {
|
||||
name: "add_effect",
|
||||
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. One undo step; if a value fails, nothing is applied. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
|
||||
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
|
|
@ -618,21 +607,6 @@ static TOOLS: &[ToolDef] = &[
|
|||
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
|
||||
run: list_effects,
|
||||
},
|
||||
ToolDef {
|
||||
name: "list_fonts",
|
||||
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
|
||||
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
},
|
||||
run: list_fonts,
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_state",
|
||||
description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.",
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ fn check(name: &str, cfg: EncoderConfig, pics: &[Pic]) -> Vec<u32> {
|
|||
let raw = std::fs::read(&output).expect("decoded yuv");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let samples: Vec<u16> =
|
||||
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
|
||||
if bd == 8 { raw.iter().map(|&b| b as u16).collect() } else { raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let fsize = w * h + 2 * cw * ch;
|
||||
assert_eq!(samples.len(), fsize * pics.len(), "{name}: decoded frame count");
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ fn description(t: Option<&[u8]>) -> Option<String> {
|
|||
// The first record: UTF-16BE.
|
||||
let len = be32(t, 20)? as usize;
|
||||
let off = be32(t, 24)? as usize;
|
||||
let u: Vec<u16> = t.get(off..off + len)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
|
||||
}
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ effectcraft-color = { workspace = true }
|
|||
effectcraft-keyframe = { workspace = true }
|
||||
effectcraft-project = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-segment = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
|
|||
match id {
|
||||
"ec.audio.backwards" => {
|
||||
if params.b("swapChannels") {
|
||||
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
|
||||
buf.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
|
||||
}
|
||||
}
|
||||
"ec.audio.basstreble" => {
|
||||
|
|
@ -471,7 +471,7 @@ fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
|
|||
let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32;
|
||||
// Smoothed gain change in dB: attack while the reduction grows, release as it shrinks.
|
||||
let (mut g, mut gs) = (0.0f64, 0.0f64);
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||||
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
|
||||
let k = if target < g { att } else { rel };
|
||||
|
|
@ -497,7 +497,7 @@ fn gate(params: &Params, buf: &mut [f32], sr: f64) {
|
|||
// Openness 0..1 (closed..open) is the gain: a closed gate mutes.
|
||||
let mut g = 0.0f64;
|
||||
let mut held = 0usize;
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||||
let open = if peak >= th {
|
||||
held = hold;
|
||||
|
|
@ -560,7 +560,7 @@ fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
|||
// Sample-and-hold on absolute sample positions, so blocks agree with one long run.
|
||||
let first = (start * sr).round() as i64;
|
||||
let mut held = [0.0f32; 2];
|
||||
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() {
|
||||
for (i, f) in buf.chunks_exact_mut(2).enumerate() {
|
||||
let take = i == 0 || (first + i as i64).rem_euclid(down) == 0;
|
||||
for ch in 0..2 {
|
||||
let x = f[ch];
|
||||
|
|
@ -696,7 +696,7 @@ fn stereo_mixer(params: &Params, buf: &mut [f32]) {
|
|||
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
|
||||
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
|
||||
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
for f in buf.chunks_exact_mut(2) {
|
||||
let (l, r) = (f[0] * ll, f[1] * rl);
|
||||
f[0] = (l * l_to_l + r * r_to_l) * inv;
|
||||
f[1] = (l * l_to_r + r * r_to_r) * inv;
|
||||
|
|
|
|||
|
|
@ -173,8 +173,7 @@ mod tests {
|
|||
std::fs::write(path, &want).unwrap();
|
||||
return;
|
||||
}
|
||||
// A Windows checkout may use CRLF; catalog content must still match exactly.
|
||||
let have = std::fs::read_to_string(path).unwrap_or_default().replace("\r\n", "\n");
|
||||
let have = std::fs::read_to_string(path).unwrap_or_default();
|
||||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1107,9 +1107,6 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.obsolete.lightning",
|
||||
"ec.text.timecode",
|
||||
"ec.text.numbers",
|
||||
// The audio visualisers read the Audio Layer's samples around the frame time.
|
||||
"ec.generate.audiospectrum",
|
||||
"ec.generate.audiowaveform",
|
||||
// Path Text's jitter changes every frame.
|
||||
"ec.obsolete.pathtext",
|
||||
// Time effects read neighbouring frames of the layer.
|
||||
|
|
|
|||
|
|
@ -16,19 +16,15 @@
|
|||
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
|
||||
//! without strokes the effect passes its input through.
|
||||
//!
|
||||
//! *Version*: 1.0 is the classic graph-cut engine; 2.0 and 3.0 also use the trained model chosen
|
||||
//! in Settings ▸ Roto Brush ([`set_model`], an `effectcraft_segment::MaskModel` such as
|
||||
//! MobileSAM), falling back to the classic engine when none is installed. The model's id is part
|
||||
//! of the chain seed, so choosing another recomputes the mattes. *Quality ▸ Best* segments at a
|
||||
//! higher working resolution with more iterations.
|
||||
//! *Version* is stored but all versions use the same (classic, graph-cut) engine; *Quality ▸
|
||||
//! Best* segments at a higher working resolution with more iterations.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_segment::MaskModel;
|
||||
use effectcraft_track::roto::refine::{self, MatteParams};
|
||||
use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle};
|
||||
use rayon::prelude::*;
|
||||
|
|
@ -177,29 +173,8 @@ pub fn matte_params(params: &Params) -> MatteParams {
|
|||
}
|
||||
}
|
||||
|
||||
fn model_slot() -> &'static RwLock<Option<Arc<dyn MaskModel>>> {
|
||||
static M: OnceLock<RwLock<Option<Arc<dyn MaskModel>>>> = OnceLock::new();
|
||||
M.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
/// The trained model Roto Brush 2.0 / 3.0 use (Settings ▸ Roto Brush; `None`: the classic
|
||||
/// engine for every version).
|
||||
pub fn set_model(m: Option<Arc<dyn MaskModel>>) {
|
||||
if let Ok(mut slot) = model_slot().write() {
|
||||
*slot = m;
|
||||
}
|
||||
}
|
||||
|
||||
/// The model an instance segments with: its version's choice of the installed one.
|
||||
pub fn model_for(params: &Params) -> Option<Arc<dyn MaskModel>> {
|
||||
if e(params, "version") == 0 {
|
||||
return None;
|
||||
}
|
||||
model_slot().read().ok().and_then(|m| m.clone())
|
||||
}
|
||||
|
||||
/// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer
|
||||
/// size, the frame rate, the segmentation settings and the model.
|
||||
/// size, the frame rate and the segmentation settings.
|
||||
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||||
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
|
||||
|
|
@ -214,7 +189,6 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
|
|||
fnv(&mut h, &v.to_bits().to_le_bytes());
|
||||
}
|
||||
fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]);
|
||||
fnv(&mut h, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
|
|
@ -301,15 +275,13 @@ pub struct Chain {
|
|||
pub keys: BTreeMap<i64, u64>,
|
||||
pub opts: SegOpts,
|
||||
pub refine_radius: f64,
|
||||
/// The trained model (Version 2.0 / 3.0 with one installed).
|
||||
pub model: Option<Arc<dyn MaskModel>>,
|
||||
}
|
||||
|
||||
impl Chain {
|
||||
pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> Chain {
|
||||
let data = data(params);
|
||||
let keys = data.chain_keys(seed(params, layer_size, fps));
|
||||
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params) }
|
||||
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale) }
|
||||
}
|
||||
|
||||
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
|
||||
|
|
@ -359,7 +331,7 @@ impl Chain {
|
|||
let img = frame(g)?;
|
||||
let strokes = self.data.strokes_at(g);
|
||||
let seg = match (&cur, g == base) {
|
||||
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
|
||||
(_, true) | (None, _) => rb::segment(&img, &strokes, None, &self.opts, self.refine_radius),
|
||||
(Some(p), false) => {
|
||||
let src = self.data.source_of(g)?;
|
||||
let pimg = match prev_img.take() {
|
||||
|
|
@ -369,7 +341,7 @@ impl Chain {
|
|||
if pimg.width != img.width || pimg.height != img.height {
|
||||
return None;
|
||||
}
|
||||
rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, self.model.as_deref())
|
||||
rb::propagate(&pimg, p, &img, &strokes, &self.opts, self.refine_radius)
|
||||
}
|
||||
};
|
||||
let seg = Arc::new(seg);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ effectcraft-path = { workspace = true }
|
|||
effectcraft-text = { workspace = true }
|
||||
effectcraft-effects = { workspace = true }
|
||||
effectcraft-render = { workspace = true }
|
||||
effectcraft-segment = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
effectcraft-model = { workspace = true }
|
||||
effectcraft-lottie = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -338,8 +338,7 @@ fn reveal(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u})));
|
||||
}
|
||||
}
|
||||
let layers: Vec<u64> = layers.iter().map(|l| l.0).collect();
|
||||
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind, "layers": layers}))?;
|
||||
frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?;
|
||||
Ok(json!({"props": out}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Composition...",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer?: classic3D|advanced3D, anchor?, open?}",
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
|
|
@ -277,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Composition Settings...",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer?: classic3D|advanced3D}",
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -136,12 +136,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
}
|
||||
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
|
||||
let comp = s.active_comp();
|
||||
let loose = s.state.selected_vertices.iter().any(|v| {
|
||||
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
|
||||
});
|
||||
if loose && p.get("layers").is_none() {
|
||||
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("mask.deleteVertices", json!({}));
|
||||
}
|
||||
// Puppet pins selected → delete the pins (not their layer).
|
||||
|
|
@ -152,12 +147,6 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
|
||||
return s.execute("effect.remove", json!({}));
|
||||
}
|
||||
// Masks, shape items, text animators or trackers selected → remove them.
|
||||
if p.get("layers").is_none()
|
||||
&& let Some(n) = super::prop_groups::remove_selected(s)?
|
||||
{
|
||||
return Ok(n);
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
|
||||
// Children of deleted layers are unparented where they are (like After Effects).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! File menu.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{FootageKind, ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_project::{FootageKind, ItemKind, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
|
|
@ -101,54 +101,6 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
open(s, &json!({"path": path}))
|
||||
}
|
||||
|
||||
/// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they
|
||||
/// also become layers of the active comp ([`add_to_comp`]).
|
||||
fn import_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = s.active_comp_id();
|
||||
let mut r = import(s, p)?;
|
||||
let errors = add_to_comp(s, &r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// With `addToComp`, put what an import (its result `r`) made into `target`, the comp that was
|
||||
/// active before it, one layer under the other, at the drop's `time`, `index` and `position`
|
||||
/// (as in `layer.addItem`): the comps of layered files (their layers' footage stays in the
|
||||
/// Project panel) and the other footage. `target` stays open. Returns what couldn't be added.
|
||||
pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option<ItemId>, p: &Value) -> Vec<String> {
|
||||
let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] };
|
||||
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
|
||||
let comps = ids("comps");
|
||||
let in_comps: std::collections::HashSet<u64> = comps
|
||||
.iter()
|
||||
.filter_map(|c| s.project.comp(ItemId(*c)))
|
||||
.flat_map(|c| c.layers.iter())
|
||||
.filter_map(|l| match l.source {
|
||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let add: Vec<u64> = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect();
|
||||
let mut errors = vec![];
|
||||
for (k, item) in add.into_iter().enumerate() {
|
||||
let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")});
|
||||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||||
params["index"] = json!(i.saturating_add(k as u64));
|
||||
}
|
||||
if let Err(e) = s.execute("layer.addItem", params) {
|
||||
errors.push(e.to_string());
|
||||
}
|
||||
}
|
||||
if s.active_comp_id() != Some(cid) {
|
||||
s.open_comp(cid);
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub(crate) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths: Vec<String> = match p.get("paths").or(p.get("path")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
|
|
@ -576,9 +528,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem)}",
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}",
|
||||
always,
|
||||
import_cmd
|
||||
import
|
||||
),
|
||||
cmd!(
|
||||
"file.projectSettings",
|
||||
|
|
|
|||
|
|
@ -438,8 +438,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if is_jsxbin(path) {
|
||||
return Err(bad("file.runScript", JSXBIN));
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
|
@ -487,14 +487,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
|
||||
}
|
||||
|
||||
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
|
||||
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
|
||||
|
||||
fn is_jsxbin(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".jsxbin")
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
|
|
@ -526,11 +519,6 @@ fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Valu
|
|||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
|
||||
// fail to parse at 1:1.
|
||||
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
|
||||
return Err(bad("script.run", JSXBIN));
|
||||
}
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,14 +46,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always),
|
||||
fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always),
|
||||
fe!(
|
||||
"playback.scrubAudio",
|
||||
"Scrub Audio",
|
||||
[],
|
||||
None,
|
||||
"{time? (s, default the current time)} — plays one frame of the comp's audio there (Ctrl/Cmd-drag the current time)",
|
||||
has_comp
|
||||
),
|
||||
// Viewer.
|
||||
fe!("view.zoomIn", "Zoom In", ["View"], Some("."), "{}", has_comp),
|
||||
fe!("view.zoomOut", "Zoom Out", ["View"], Some(","), "{}", has_comp),
|
||||
|
|
@ -72,14 +64,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fe!("view.options", "View Options...", ["View"], Some("Cmd+Alt+U"), "{}", has_comp),
|
||||
fe!("view.layerControls", "Show Layer Controls", ["View"], Some("Cmd+Shift+H"), "{value?}", always),
|
||||
fe!("view.fullScreen", "Enter Full Screen", ["View"], Some("Ctrl+Cmd+F"), "{}", always),
|
||||
fe!(
|
||||
"view.newViewer",
|
||||
"New Viewer",
|
||||
["View"],
|
||||
Some("Alt+Shift+N"),
|
||||
"{} — another Composition viewer of the active comp; the one in use is locked",
|
||||
has_comp
|
||||
),
|
||||
// Panels and workspaces.
|
||||
fe!(
|
||||
"window.panel",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Other ArtCraft Apps",
|
||||
[],
|
||||
None,
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
|
||||
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
|
||||
always,
|
||||
sibling
|
||||
),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) {
|
||||
let n = (buf.len() / 2).max(1) as f64;
|
||||
let (mut l, mut r) = (0.0f64, 0.0f64);
|
||||
for c in buf.as_chunks::<2>().0 {
|
||||
for c in buf.chunks_exact(2) {
|
||||
l += c[0].abs() as f64;
|
||||
r += c[1].abs() as f64;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,58 +24,13 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
|
|||
}
|
||||
|
||||
/// Insert a new layer above the selection (or at the top) and select it.
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
|
||||
insert_layer_at(proj, st, cid, layer, None)
|
||||
}
|
||||
|
||||
/// `index`: an `index` parameter, the layer's 1-based place in the stack (1 = top; past the
|
||||
/// bottom = the bottom), as 0-based.
|
||||
pub(crate) fn index_p(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
v.as_u64().filter(|i| *i >= 1).map(|i| Some(usize::try_from(i - 1).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, "index: 1-based stack position"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `position` parameter: `[x, y]` in comp pixels (finite).
|
||||
pub(crate) fn position_p(p: &Value, cmd: &str) -> Result<Option<[f64; 2]>> {
|
||||
match p.get("position") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]))
|
||||
.filter(|c| c.iter().all(|x| x.is_finite()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, "position: [x, y] in comp pixels")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a new layer's Position at `pos` (comp pixels).
|
||||
pub(crate) fn place(l: &mut Layer, pos: Option<[f64; 2]>) {
|
||||
if let Some([x, y]) = pos
|
||||
&& let Some(pr) = l.props.prop_mut("transform/position")
|
||||
{
|
||||
let z = match pr.value {
|
||||
KV::Vec3(v) => v[2],
|
||||
_ => 0.0,
|
||||
};
|
||||
pr.value = KV::Vec3([x, y, z]);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`insert_layer`] at stack position `at` (0 = top) instead of above the selected layer.
|
||||
pub(crate) fn insert_layer_at(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer, at: Option<usize>) -> Result<LayerId> {
|
||||
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
|
||||
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
|
||||
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
|
||||
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = match at {
|
||||
Some(i) => i.min(comp.layers.len()),
|
||||
None => st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0),
|
||||
};
|
||||
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
|
||||
let id = layer.id;
|
||||
comp.layers.insert(at, layer);
|
||||
st.selected_layers = vec![id];
|
||||
|
|
@ -303,17 +258,12 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(
|
||||
s,
|
||||
&serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}),
|
||||
);
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
}
|
||||
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let index = index_p(p, "layer.addItem")?;
|
||||
let position = position_p(p, "layer.addItem")?;
|
||||
let fr = comp.frame_rate;
|
||||
// Settings ▸ General ▸ Create Layers at Composition Start Time (off: at the current time).
|
||||
let default_start = if s.prefs.general.create_layers_at_comp_start || s.active_comp_id() != Some(cid) { Tick::ZERO } else { s.time() };
|
||||
|
|
@ -323,8 +273,7 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
|
@ -663,45 +612,6 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Validate the proposed ancestor chain before any authored edit, including when compensation
|
||||
/// is disabled. Bound both unique ancestors and linear lookup/selection work on loaded graphs.
|
||||
fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option<LayerId>, max_visits: usize, max_work: usize) -> Result<()> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut work = 0usize;
|
||||
let mut cur = parent;
|
||||
while let Some(id) = cur {
|
||||
if seen.len() >= max_visits {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety visit limit"));
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
return Err(bad("layer.setParent", "parent chain contains an existing cycle"));
|
||||
}
|
||||
for selected in ids {
|
||||
if work >= max_work {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
|
||||
}
|
||||
work += 1;
|
||||
if *selected == id {
|
||||
return Err(bad("layer.setParent", "parenting would create a cycle"));
|
||||
}
|
||||
}
|
||||
let mut ancestor = None;
|
||||
for layer in &comp.layers {
|
||||
if work >= max_work {
|
||||
return Err(bad("layer.setParent", "parent chain exceeds the safety work limit"));
|
||||
}
|
||||
work += 1;
|
||||
if layer.id == id {
|
||||
ancestor = Some(layer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let layer = ancestor.ok_or_else(|| bad("layer.setParent", "parent chain refers to a missing layer"))?;
|
||||
cur = layer.parent;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -709,9 +619,16 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setParent", "no such parent layer"))?),
|
||||
};
|
||||
// A loaded cycle need not include a selected child. Validate before compensation or edit;
|
||||
// do not impose the renderer's 64-ancestor truncation as a new ordinary hierarchy limit.
|
||||
check_parent_chain(comp, &ids, parent, 4096, 1_048_576)?;
|
||||
// Cycle check.
|
||||
if let Some(par) = parent {
|
||||
let mut cur = Some(par);
|
||||
while let Some(c) = cur {
|
||||
if ids.contains(&c) {
|
||||
return Err(bad("layer.setParent", "parenting would create a cycle"));
|
||||
}
|
||||
cur = comp.layer(c).and_then(|l| l.parent);
|
||||
}
|
||||
}
|
||||
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
|
||||
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
|
||||
let compensate = b_p(p, "compensate").unwrap_or(true);
|
||||
|
|
@ -1075,9 +992,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
|
||||
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
|
||||
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
|
||||
"path" => build::shape_path(&mut ids, Default::default()),
|
||||
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
|
||||
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
|
||||
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
|
||||
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
|
||||
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
|
||||
|
|
@ -1327,15 +1242,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layer_settings,
|
||||
layer_settings
|
||||
),
|
||||
cmd!(
|
||||
"layer.addItem",
|
||||
"Add Footage to Comp",
|
||||
["File"],
|
||||
Some("Cmd+/"),
|
||||
"{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}",
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!("layer.addItem", "Add Footage to Comp", ["File"], Some("Cmd+/"), "{item: id|name, time?, duration? (s, for a still)}", has_comp, add_item),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
|
@ -1406,7 +1313,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add (Shape)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
|
||||
has_layers,
|
||||
add_shape_item
|
||||
),
|
||||
|
|
@ -1433,120 +1340,3 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: &Comp) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parent_chain_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> (Session, u64, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name":"Parent safety","width":16,"height":16,"duration":1})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name":"A"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name":"B"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let c = s.execute("layer.newSolid", json!({"name":"C"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for id in [a, b, c] {
|
||||
s.execute("prop.set", json!({"layer":id,"path":"transform/anchor","value":[0,0,0]})).unwrap();
|
||||
}
|
||||
s.execute("prop.set", json!({"layer":a,"path":"transform/position","value":[100,200,0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer":c,"path":"transform/position","value":[10,20,0]})).unwrap();
|
||||
s.history.undo.clear();
|
||||
(s, a, b, c)
|
||||
}
|
||||
fn malformed_parent(s: &mut Session, child: u64, parent: u64) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(child)).unwrap().parent = Some(LayerId(parent));
|
||||
}
|
||||
fn assert_atomic_error(s: &mut Session, child: u64, parent: u64, compensate: bool, message: &str) {
|
||||
let before = s.project.clone();
|
||||
let revision = s.revision;
|
||||
let undo = s.history.undo.len();
|
||||
let err = s.execute("layer.setParent", json!({"layers":[child],"parent":parent,"compensate":compensate})).unwrap_err();
|
||||
assert!(err.to_string().contains(message), "{err}");
|
||||
assert!(Arc::ptr_eq(&before, &s.project));
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
#[test]
|
||||
fn loaded_cycle_excluding_selected_child_is_rejected_atomically() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, b, c) = fixture();
|
||||
// Simulate a malformed loaded project. Never execute this on the unguarded baseline.
|
||||
malformed_parent(&mut s, a, b);
|
||||
malformed_parent(&mut s, b, a);
|
||||
assert_atomic_error(&mut s, c, a, compensate, "existing cycle");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn dangling_loaded_parent_is_rejected_atomically() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
malformed_parent(&mut s, a, u64::MAX);
|
||||
assert_atomic_error(&mut s, c, a, compensate, "missing layer");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn selected_descendant_and_direct_self_are_rejected() {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
assert_atomic_error(&mut s, c, c, false, "create a cycle");
|
||||
s.execute("layer.setParent", json!({"layers":[a],"parent":c,"compensate":false})).unwrap();
|
||||
assert_atomic_error(&mut s, c, a, true, "create a cycle");
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_parent_and_unparent_keep_explicit_false_and_undo_semantics() {
|
||||
for compensate in [true, false] {
|
||||
let (mut s, a, _, c) = fixture();
|
||||
let old = s.project.clone();
|
||||
let world = |s: &Session| {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||
ctx.world_matrix(comp.layer(LayerId(c)).unwrap()).apply(effectcraft_geom::Vec3::ZERO)
|
||||
};
|
||||
let before = world(&s);
|
||||
s.execute("layer.setParent", json!({"layers":[c],"parent":a,"compensate":compensate})).unwrap();
|
||||
let expected = if compensate { before } else { before + effectcraft_geom::vec3(100.0, 200.0, 0.0) };
|
||||
assert!((world(&s) - expected).length() < 1e-9);
|
||||
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, Some(LayerId(a)));
|
||||
assert_eq!(s.history.undo.len(), 1);
|
||||
let parented = s.project.clone();
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&s.project, &old));
|
||||
assert!(s.redo());
|
||||
assert!(Arc::ptr_eq(&s.project, &parented));
|
||||
s.execute("layer.setParent", json!({"layers":[c],"parent":null,"compensate":compensate})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layer(LayerId(c)).unwrap().parent, None);
|
||||
assert!(s.undo());
|
||||
assert!(Arc::ptr_eq(&s.project, &parented));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ancestor_and_lookup_work_budgets_fail_closed() {
|
||||
let (mut s, a, b, c) = fixture();
|
||||
malformed_parent(&mut s, a, b);
|
||||
let comp = s.active_comp().unwrap();
|
||||
// New solids are inserted above the selection: C,B,A. Walking A->B scans
|
||||
// three then two layers, and compares the one selected child twice: seven work units.
|
||||
assert_eq!(comp.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![LayerId(c), LayerId(b), LayerId(a)]);
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 7).is_ok());
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 6).unwrap_err().to_string().contains("work limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 256).is_ok());
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 1, 256).unwrap_err().to_string().contains("visit limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], Some(LayerId(a)), 2, 1).unwrap_err().to_string().contains("work limit"));
|
||||
assert!(check_parent_chain(comp, &[LayerId(c)], None, 0, 0).is_ok(), "unparenting does not traverse ancestors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_chain_beyond_renderer_depth_is_not_unnecessarily_rejected() {
|
||||
let (s, _, _, c) = fixture();
|
||||
let mut comp = s.active_comp().unwrap().clone();
|
||||
let template = comp.layers.first().unwrap().clone();
|
||||
for n in 0..80 {
|
||||
let mut layer = template.clone();
|
||||
layer.id = LayerId(10_000 + n);
|
||||
layer.parent = (n < 79).then_some(LayerId(10_001 + n));
|
||||
comp.layers.push(layer);
|
||||
}
|
||||
assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! comp seconds; layer markers are stored in layer time. All commands are undoable.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project};
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, LayerId, LayerSource, Marker};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -85,56 +85,6 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Array(ms.iter().enumerate().map(|(i, m)| marker_json(c, o, i, m)).collect()))
|
||||
}
|
||||
|
||||
/// The nested comp's markers on a precomp layer, at their times in the containing comp `comp`
|
||||
/// and within the layer's In–Out range, as After Effects shows them on the precomp layer's bar:
|
||||
/// `(comp time, index in the nested comp, marker)`. The layer's start time and stretch map them;
|
||||
/// with time remapping, or a stretch over protected regions (Responsive Design), a marker sits at
|
||||
/// the first frame the layer shows its time.
|
||||
pub fn nested_markers<'a>(project: &'a Project, comp_id: ItemId, comp: &Comp, layer: &Layer) -> Vec<(Tick, usize, &'a Marker)> {
|
||||
let LayerSource::Comp { item } = layer.source else { return vec![] };
|
||||
let Some(nc) = project.comp(item) else { return vec![] };
|
||||
if nc.markers.is_empty() || item == comp_id {
|
||||
return vec![];
|
||||
}
|
||||
let shown = |t: Tick| t >= layer.in_point && t < layer.out_point;
|
||||
let stretched_regions = (layer.stretch - 100.0).abs() > 1e-9 && nc.markers.iter().any(|m| m.protected);
|
||||
if layer.props.get("timeRemap").is_none() && !stretched_regions {
|
||||
return nc.markers.iter().enumerate().map(|(i, m)| (layer.comp_time(m.time), i, m)).filter(|(t, _, _)| shown(*t)).collect();
|
||||
}
|
||||
// The layer's source time at every frame of its range; each marker at the first frame that
|
||||
// reaches it (within rounding: a thousandth of a nested frame).
|
||||
let fr = comp.frame_rate;
|
||||
let (f0, f1) = (fr.frame_at(layer.in_point), fr.frame_at(layer.out_point));
|
||||
let frames: Vec<(Tick, Tick)> = (f0..f1.min(f0.saturating_add(100_000)))
|
||||
.map(|f| {
|
||||
let t = fr.tick_of(f);
|
||||
(t, effectcraft_render::EvalCtx::new(project, comp_id, comp, t).source_time(layer))
|
||||
})
|
||||
.collect();
|
||||
let eps = nc.frame_duration().0 / 1000;
|
||||
nc.markers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| frames.iter().find(|(_, src)| src.0.saturating_add(eps) >= m.time.0).map(|(t, _)| (*t, i, m)))
|
||||
.filter(|(t, _, _)| shown(*t))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `markers.nested`: the nested comp's markers on a precomp layer (see [`nested_markers`]).
|
||||
fn nested(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "markers.nested";
|
||||
let cid = comp_id(s, p)?;
|
||||
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let lid = p.get("layer").and_then(|v| resolve_layer(c, v)).ok_or_else(|| bad(cmd, "missing `layer` (a precomp layer)"))?;
|
||||
let l = c.layer(lid).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
let LayerSource::Comp { item } = l.source else { return Err(bad(cmd, "not a precomp layer")) };
|
||||
let v: Vec<Value> = nested_markers(&s.project, cid, c, l)
|
||||
.into_iter()
|
||||
.map(|(t, i, m)| json!({"time": t.seconds(), "comp": item.0, "index": i, "nestedTime": m.time.seconds(), "duration": m.duration.seconds(), "comment": m.comment, "label": m.label.name()}))
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
}
|
||||
|
||||
fn locked(c: &Comp, o: Owner) -> bool {
|
||||
matches!(o, Owner::Layer(l) if c.layer(l).is_some_and(|l| l.markers_locked))
|
||||
}
|
||||
|
|
@ -349,12 +299,6 @@ fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> {
|
|||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("markers.list", "List Markers", "{layer? (omit for composition markers)}", list),
|
||||
query!(
|
||||
"markers.nested",
|
||||
"Nested Comp Markers",
|
||||
"{layer: a precomp layer, comp?} — its comp's markers at their times in this comp (as on the layer bar)",
|
||||
nested
|
||||
),
|
||||
cmd!(
|
||||
"markers.set",
|
||||
"Marker Settings",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
path,
|
||||
method,
|
||||
times: times.clone(),
|
||||
face_model: if method.is_face() { s.models.face() } else { None },
|
||||
};
|
||||
s.state.mask_track_method = method;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
|
|
@ -196,8 +195,6 @@ fn track_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({
|
||||
"mask": uid,
|
||||
"method": method.id(),
|
||||
// The engine face tracking tries first (the classic one when the model finds no face).
|
||||
"faceModel": method.is_face().then(|| s.models.face().map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)),
|
||||
"frames": times.len() - 1,
|
||||
"start": times[0].seconds(),
|
||||
"end": times.last().map(|t| t.seconds()),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ mod layer_time;
|
|||
pub(crate) mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
pub mod markers;
|
||||
mod markers;
|
||||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
|
|
@ -54,7 +54,7 @@ pub mod roto_cmds;
|
|||
mod scene_detect;
|
||||
pub mod scripts;
|
||||
mod settings;
|
||||
pub mod shape_stroke;
|
||||
mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
|
|
@ -133,7 +133,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(crate::models::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(comp::specs());
|
||||
v.extend(layer::specs());
|
||||
|
|
@ -442,10 +441,9 @@ pub(crate) fn merge_p(p: &Value) -> Option<&str> {
|
|||
|
||||
/// `comp`: item id or name; default the active comp.
|
||||
pub(crate) fn comp_id(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
let missing = |v: &Value| EngineError::NoSuchComp(v.to_string());
|
||||
match p.get("comp") {
|
||||
Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)),
|
||||
Some(v @ Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or_else(|| missing(v)),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or(EngineError::NoComp),
|
||||
Some(Value::String(name)) => s.project.items.values().find(|i| &i.name == name && i.as_comp().is_some()).map(|i| i.id).ok_or(EngineError::NoComp),
|
||||
_ => s.active_comp_id().ok_or(EngineError::NoComp),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Nod
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::{color_p, index_p, insert_layer, insert_layer_at, place, position_p};
|
||||
use super::layer::{color_p, insert_layer};
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
|
|
@ -119,8 +119,6 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let index = index_p(p, "layer.newModel")?;
|
||||
let position = position_p(p, "layer.newModel")?;
|
||||
let id = s.edit("New 3D Model Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
|
||||
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
|
||||
|
|
@ -130,8 +128,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = comp.duration.max(start + fr.frame_duration());
|
||||
place(&mut l, position);
|
||||
insert_layer_at(proj, st, cid, l, index)
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "item": item.0}))
|
||||
}
|
||||
|
|
@ -352,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
|
|
|||
|
|
@ -104,14 +104,15 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if !browser(s)?.fetch(&list, p) {
|
||||
return Ok(json!({"pending": true, "paths": list}));
|
||||
}
|
||||
let target = s.active_comp_id();
|
||||
let mut r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped.
|
||||
let errors = super::file::add_to_comp(s, &r, target, p);
|
||||
if !errors.is_empty()
|
||||
&& let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut)
|
||||
let r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Add to the active comp (drag into the timeline).
|
||||
if b_p(p, "addToComp").unwrap_or(false)
|
||||
&& s.active_comp_id().is_some()
|
||||
&& let Some(items) = r.get("items").and_then(Value::as_array)
|
||||
{
|
||||
e.extend(errors.into_iter().map(Value::from));
|
||||
for it in items {
|
||||
s.execute("layer.addItem", json!({"item": it}))?;
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
|
@ -232,15 +233,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("mediaBrowser.go", "Go to Folder", [], None, "{path?: folder | \"..\", importableOnly?}", browser_enabled, browser_go),
|
||||
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
|
||||
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
|
||||
cmd!(
|
||||
"mediaBrowser.import",
|
||||
"Import",
|
||||
[],
|
||||
None,
|
||||
"{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}",
|
||||
browser_enabled,
|
||||
browser_import
|
||||
),
|
||||
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
|
||||
cmd!(
|
||||
"mediaBrowser.action",
|
||||
"Media Browser Action",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, Project};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// `item` (id or name) → id.
|
||||
fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result<ItemId> {
|
||||
|
|
@ -140,40 +140,17 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The items deleting `ids` removes: them and, for folders, everything inside.
|
||||
fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec<ItemId> {
|
||||
project.items.keys().copied().filter(|id| ids.iter().any(|d| is_within(project, *id, *d))).collect()
|
||||
}
|
||||
|
||||
/// What deleting `items` takes with it: (layers using them, in how many comps), counting only
|
||||
/// the comps that stay.
|
||||
fn usage_of(project: &Project, items: &[ItemId]) -> (usize, usize) {
|
||||
let mut layers = 0;
|
||||
let mut comps = 0;
|
||||
for (_, comp) in project.comps().filter(|(cid, _)| !items.contains(cid)) {
|
||||
let n = comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| items.contains(&i))).count();
|
||||
layers += n;
|
||||
comps += usize::from(n > 0);
|
||||
}
|
||||
(layers, comps)
|
||||
}
|
||||
|
||||
/// What deleting the items (default: selected) would delete: the items (with folder contents)
|
||||
/// and the layers in other compositions that use them. The Project panel asks before deleting
|
||||
/// items in use, as After Effects does.
|
||||
fn usage(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = items_p(s, p, "project.usage")?;
|
||||
let doomed = doomed_items(&s.project, &ids);
|
||||
let (layers, comps) = usage_of(&s.project, &doomed);
|
||||
Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps}))
|
||||
}
|
||||
|
||||
/// Delete project items (folders with their contents). Layers that use a deleted item and Render
|
||||
/// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel.
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "project.delete";
|
||||
let ids = items_p(s, p, c)?;
|
||||
let doomed = doomed_items(&s.project, &ids);
|
||||
let mut doomed: Vec<ItemId> = vec![];
|
||||
for id in s.project.items.keys() {
|
||||
if ids.iter().any(|d| is_within(&s.project, *id, *d)) {
|
||||
doomed.push(*id);
|
||||
}
|
||||
}
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
|
|
@ -265,7 +242,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always,
|
||||
delete
|
||||
),
|
||||
query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage),
|
||||
cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
|
||||
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
|
||||
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
|
||||
use effectcraft_project::{ItemId, LayerId, Property, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
|
||||
|
||||
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
|
||||
pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
|
|
@ -118,14 +118,8 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
|
||||
});
|
||||
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
|
||||
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
|
||||
// disabled by its syntax error (prop.setExpression) reports that error.
|
||||
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
|
||||
out["evaluated"] = raw.to_json();
|
||||
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
|
||||
out["expressionError"] = json!(err);
|
||||
}
|
||||
} else if pr.has_expression()
|
||||
// expression makes of it (what renders), or why it fails.
|
||||
if pr.has_expression()
|
||||
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
|
||||
{
|
||||
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
|
|
@ -293,19 +287,11 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let add = b_p(p, "add").unwrap_or(false);
|
||||
if !add {
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
}
|
||||
if !s.state.selected_layers.contains(&lid) {
|
||||
s.state.selected_layers = vec![lid];
|
||||
}
|
||||
s.state.selected_props.push((lid, uid));
|
||||
// Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the
|
||||
// viewer drags the whole mask (as in AE, #203).
|
||||
let t = s.time();
|
||||
if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) {
|
||||
s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask));
|
||||
s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index }));
|
||||
}
|
||||
// Selecting a property selects all its keys (as in AE).
|
||||
if b_p(p, "selectKeys").unwrap_or(true)
|
||||
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
|
||||
|
|
@ -319,23 +305,6 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
|
||||
/// of points at layer time `lt`.
|
||||
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
|
||||
let owner = match l.props.find_group(uid) {
|
||||
Some(g) => g,
|
||||
None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?,
|
||||
};
|
||||
let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some());
|
||||
if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents {
|
||||
return None;
|
||||
}
|
||||
match owner.get("path")?.value_at(lt) {
|
||||
effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- keyframes
|
||||
|
||||
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -51,38 +51,17 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?;
|
||||
remove_groups(s, cid, &[(lid, uid)])?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Remove groups of comp `cid` in one undo step.
|
||||
fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)]) -> Result<()> {
|
||||
let c = "prop.removeGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
s.edit("Delete", None, |proj, st| {
|
||||
for (lid, uid) in groups {
|
||||
// A group inside one removed before it went with it.
|
||||
if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) {
|
||||
parent.children.retain(|n| n.uid() != *uid);
|
||||
}
|
||||
}
|
||||
st.selected_props.retain(|(_, u)| !groups.iter().any(|(_, g)| g == u));
|
||||
Ok(())
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let parent = l.props.parent_of_mut(uid).ok_or_else(|| bad(c, "group vanished"))?;
|
||||
parent.children.retain(|n| n.uid() != uid);
|
||||
st.selected_props.retain(|(_, u)| *u != uid);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Edit ▸ Clear with masks, shape items, text animators or trackers selected in the active comp:
|
||||
/// removes those groups (not their layers) in one undo step. None when none are selected.
|
||||
pub(crate) fn remove_selected(s: &mut Session) -> Result<Option<Value>> {
|
||||
let (Some(cid), Some(c)) = (s.active_comp_id(), s.active_comp()) else { return Ok(None) };
|
||||
let groups: Vec<(LayerId, Uid)> =
|
||||
s.state.selected_props.iter().filter(|(lid, uid)| c.layer(*lid).and_then(|l| l.props.find_group(*uid)).is_some_and(is_instance)).copied().collect();
|
||||
if groups.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
remove_groups(s, cid, &groups)?;
|
||||
Ok(Some(json!(groups.len())))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.duplicateGroup";
|
||||
let (cid, lid, uid) = group_ref(s, p, c, true)?;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
|
|||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||||
}
|
||||
|
||||
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. The input comes
|
||||
/// from the layer cache, shared: moving pins doesn't change it (#212).
|
||||
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(std::sync::Arc<Buf>, Params)> {
|
||||
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`.
|
||||
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> {
|
||||
let comp = s.project.comp(cid)?;
|
||||
let layer = comp.layer(lid)?;
|
||||
let fx = layer.effects()?;
|
||||
|
|
@ -38,7 +37,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
|||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
let buf = r.layer_input(&ctx, layer, index)?;
|
||||
let buf = (*r.layer_input(&ctx, layer, index)?).clone();
|
||||
let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p));
|
||||
Some((buf, params))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use crate::{EngineError, Result, Session, cmd, query};
|
|||
|
||||
fn can_add(s: &Session) -> std::result::Result<(), String> {
|
||||
not_rendering(s)?;
|
||||
if s.comp_for_queue(&Value::Null).is_ok() { Ok(()) } else { Err("select or open a composition".into()) }
|
||||
if s.comp_for_queue(&Value::Null).is_some() { Ok(()) } else { Err("select or open a composition".into()) }
|
||||
}
|
||||
fn not_rendering(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_rendering() { Err("the render queue is rendering".into()) } else { Ok(()) }
|
||||
|
|
@ -202,9 +202,6 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
|
|||
om.name = t.name.clone();
|
||||
}
|
||||
|
||||
/// The `format` parameter's values.
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff";
|
||||
|
||||
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
|
||||
/// (captured by `crop.useRoi`).
|
||||
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
|
||||
|
|
@ -216,12 +213,9 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
}
|
||||
let before = om.clone();
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om.set_format(f);
|
||||
}
|
||||
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
|
||||
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(c) = str_p(p, "channels") {
|
||||
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
|
||||
"rgb" => Channels::Rgb,
|
||||
|
|
@ -230,11 +224,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
|
||||
};
|
||||
if om.channels == Channels::Rgba && !om.supports_alpha() {
|
||||
let what = match om.format {
|
||||
OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()),
|
||||
f => format!("{} has no alpha channel", f.label()),
|
||||
};
|
||||
return Err(bad(cmd, what));
|
||||
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
|
||||
}
|
||||
}
|
||||
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
|
||||
|
|
@ -244,6 +234,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "color: straight|premultiplied")),
|
||||
};
|
||||
}
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(q) = f_p(p, "quality") {
|
||||
om.quality = q.clamp(1.0, 100.0) as u8;
|
||||
}
|
||||
|
|
@ -366,18 +357,13 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
}
|
||||
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
|
||||
om.output = o.to_string();
|
||||
// Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's.
|
||||
if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) {
|
||||
if p.get("format").is_some() {
|
||||
// An explicit format wins and the extension follows it, as choosing a format in
|
||||
// the Output Module renames Output To (#154: `--format hevc --out x.mp4`).
|
||||
om.set_format(om.format);
|
||||
} else {
|
||||
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
|
||||
let keep = om.output.clone();
|
||||
om.set_format(f);
|
||||
om.output = keep;
|
||||
}
|
||||
if p.get("format").is_none()
|
||||
&& let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]"))
|
||||
{
|
||||
// `out.mov` with an H.264 module → ProRes, like picking a file type in Output To.
|
||||
let keep = om.output.clone();
|
||||
om.set_format(f);
|
||||
om.output = keep;
|
||||
}
|
||||
}
|
||||
if *om != before {
|
||||
|
|
@ -510,20 +496,24 @@ fn item_json(s: &Session, it: &RenderQueueItem, index: usize) -> Value {
|
|||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p)?;
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
// New items start from the Movie Default templates (Edit ▸ Templates).
|
||||
let templates = s.project.render_templates.clone();
|
||||
it.settings = templates.default_render_settings(TemplateSlot::Movie);
|
||||
it.output = templates.default_output_module(TemplateSlot::Movie);
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
if f != it.output.format {
|
||||
it.output = OutputModule::for_format(f);
|
||||
}
|
||||
}
|
||||
apply_settings(&mut it.settings, &templates, p, "renderQueue.add")?;
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "renderQueue.add")?;
|
||||
let mut op = p.clone();
|
||||
if let Some(o) = op.as_object_mut() {
|
||||
o.remove("format");
|
||||
}
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, &op, "renderQueue.add")?;
|
||||
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
|
||||
it.log = l;
|
||||
}
|
||||
|
|
@ -714,7 +704,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let n = it.extra_outputs.len() + 2;
|
||||
let mut om = it.output.clone();
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", FORMATS))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om = OutputModule::for_format(f);
|
||||
}
|
||||
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
|
||||
|
|
@ -724,7 +714,11 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
None => format!("{}_{n}", om.output),
|
||||
};
|
||||
}
|
||||
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, p, "render.addOutputModule")?;
|
||||
let mut op = p.clone();
|
||||
if let Some(o) = op.as_object_mut() {
|
||||
o.remove("format");
|
||||
}
|
||||
apply_output(&mut om, &s.project.render_templates, s.state.region_of_interest, &op, "render.addOutputModule")?;
|
||||
s.edit("Add Output Module", None, |proj, _| {
|
||||
let it = &mut proj.render_queue[i];
|
||||
it.extra_outputs.push(om);
|
||||
|
|
@ -739,7 +733,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// Composition ▸ Pre-render…: queue the comp with a lossless-with-alpha module whose post-render
|
||||
/// action imports the result and replaces the comp's uses.
|
||||
fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p)?;
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
let templates = s.project.render_templates.clone();
|
||||
it.settings = templates.default_render_settings(TemplateSlot::PreRender);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
|
||||
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
|
||||
//! 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.
|
||||
//! they existed.
|
||||
|
||||
use effectcraft_keyframe::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 effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::color_p;
|
||||
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn is_stroke(g: &PropGroup) -> bool {
|
||||
|
|
@ -197,116 +195,8 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Path items a Fill or Stroke paints in their group.
|
||||
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
|
||||
|
||||
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
|
||||
/// `lt` (what the toolbar shows for a selected shape layer).
|
||||
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
|
||||
let (mut fill, mut stroke, mut width) = (None, None, None);
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
walk_groups(c, &mut |g| {
|
||||
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
|
||||
let rgba = |v: KV| v.as_color().map(f64::from);
|
||||
match g.match_id.as_str() {
|
||||
"fill" if fill.is_none() => fill = at("color").map(rgba),
|
||||
"stroke" if stroke.is_none() => {
|
||||
stroke = at("color").map(rgba);
|
||||
width = at("width").map(|v| v.as_f64());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
|
||||
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
|
||||
/// its fills, as new shapes have it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if 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());
|
||||
}
|
||||
for n in &mut c.children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
let set = |g: &mut PropGroup, id: &str, v: Option<KV>| {
|
||||
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
|
||||
p.set_value_at(lt, v);
|
||||
}
|
||||
};
|
||||
match g.match_id.as_str() {
|
||||
"fill" => set(g, "color", fill.map(KV::Color)),
|
||||
"stroke" => {
|
||||
set(g, "color", stroke.map(KV::Color));
|
||||
set(g, "width", width.map(KV::Scalar));
|
||||
}
|
||||
"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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
|
||||
if shapes.is_empty() {
|
||||
return Err(bad(c, "select a shape layer"));
|
||||
}
|
||||
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 t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &shapes {
|
||||
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);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
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}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shape.fillStroke",
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
|
||||
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
|
||||
cmd!(
|
||||
|
|
|
|||
|
|
@ -498,33 +498,6 @@ pub fn font_features(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}))
|
||||
}
|
||||
|
||||
/// The installed font families (bundled and system), with their styles, where each comes from
|
||||
/// and its name in the font's own language. `query` keeps the families whose English or native
|
||||
/// name contains it (case-insensitive); `rescan` first looks for fonts installed since the app
|
||||
/// started.
|
||||
pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_text::fonts;
|
||||
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
|
||||
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
|
||||
let native = fonts::native_families();
|
||||
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
|
||||
for f in fonts::all_faces() {
|
||||
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
|
||||
}
|
||||
let list: Vec<Value> = effectcraft_text::families()
|
||||
.into_iter()
|
||||
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
|
||||
.map(|(f, styles)| {
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
|
||||
if let Some(n) = native.get(&f) {
|
||||
v["nativeName"] = json!(n);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"count": list.len(), "families": list, "added": added}))
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -554,15 +527,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
always_ok,
|
||||
font_features
|
||||
),
|
||||
cmd!(
|
||||
"text.fonts",
|
||||
"List Fonts",
|
||||
[],
|
||||
None,
|
||||
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
|
||||
always_ok,
|
||||
fonts
|
||||
),
|
||||
cmd!(
|
||||
"text.setSelection",
|
||||
"Set Text Selection",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ pub mod mask_track;
|
|||
pub mod media_browser;
|
||||
pub mod media_cache;
|
||||
pub mod menus;
|
||||
pub mod models;
|
||||
pub mod offload;
|
||||
pub mod perf;
|
||||
pub mod prefs;
|
||||
|
|
@ -64,7 +63,6 @@ pub use effectcraft_keyframe as keyframe;
|
|||
pub use effectcraft_project as project;
|
||||
pub use effectcraft_raster as raster;
|
||||
pub use effectcraft_render as render;
|
||||
pub use effectcraft_segment as segment;
|
||||
pub use effectcraft_text as text;
|
||||
pub use effectcraft_time as time;
|
||||
pub use effectcraft_track as track;
|
||||
|
|
@ -81,9 +79,6 @@ pub enum EngineError {
|
|||
BadParams { cmd: String, msg: String },
|
||||
#[error("no active composition")]
|
||||
NoComp,
|
||||
/// A `comp` reference (name or id) that names no composition.
|
||||
#[error("no composition {0}")]
|
||||
NoSuchComp(String),
|
||||
#[error("{0}")]
|
||||
Project(#[from] effectcraft_project::ProjectError),
|
||||
#[error("{0}")]
|
||||
|
|
@ -352,10 +347,6 @@ pub struct Session {
|
|||
pub camera_pending: Vec<(ItemId, LayerId, Uid)>,
|
||||
/// The running (or finished, not yet polled) Roto Brush propagation / Freeze.
|
||||
pub roto_job: Option<roto::RotoJob>,
|
||||
/// Trained models for Roto Brush and face tracking (Settings ▸ Roto Brush / Face Tracking).
|
||||
pub models: models::Models,
|
||||
/// Where installed models live (hosts set it; else `models` next to the settings).
|
||||
pub models_dir: Option<std::path::PathBuf>,
|
||||
/// Roto Brush instances edited since their last propagation: (comp, layer, effect uid).
|
||||
pub roto_pending: Vec<(ItemId, LayerId, Uid)>,
|
||||
pub events: Vec<Event>,
|
||||
|
|
@ -472,8 +463,6 @@ impl Default for Session {
|
|||
camera_job: None,
|
||||
camera_pending: vec![],
|
||||
roto_job: None,
|
||||
models: Default::default(),
|
||||
models_dir: None,
|
||||
roto_pending: vec![],
|
||||
events: vec![],
|
||||
journal: vec![],
|
||||
|
|
@ -1021,17 +1010,6 @@ pub fn text_families() -> Vec<String> {
|
|||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
}
|
||||
|
||||
/// The styles a family offers (its own faces, in weight order), for the style menus. A family
|
||||
/// that isn't installed offers the usual styles, which resolve to Inter's.
|
||||
pub fn font_styles(family: &str) -> Vec<String> {
|
||||
effectcraft_text::families()
|
||||
.into_iter()
|
||||
.find(|(f, _)| f.eq_ignore_ascii_case(family))
|
||||
.map(|(_, st)| st)
|
||||
.filter(|st| !st.is_empty())
|
||||
.unwrap_or_else(|| ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
/// The OpenType feature tags (GSUB / GPOS) of the face a family + style resolves to.
|
||||
pub fn font_features(family: &str, style: &str) -> Vec<String> {
|
||||
effectcraft_text::fonts::face(effectcraft_text::resolve(family, style).face).features()
|
||||
|
|
@ -1101,8 +1079,6 @@ pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
|
|||
#[cfg(test)]
|
||||
mod tests_history;
|
||||
#[cfg(test)]
|
||||
mod tests_models;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ pub const SIBLINGS: &[(&str, &str)] = &[
|
|||
("VectorCraft", "vectorcraft"),
|
||||
("FilmCraft", "filmcraft"),
|
||||
("LightCraft", "lightcraft"),
|
||||
("PdfCraft", "pdfcraft"),
|
||||
("PrintCraft", "printcraft"),
|
||||
("DesignCraft", "designcraft"),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -198,8 +198,6 @@ pub(crate) struct MaskWork {
|
|||
pub method: MaskMethod,
|
||||
/// Comp times; the first is the start frame.
|
||||
pub times: Vec<Tick>,
|
||||
/// Face tracking's trained model (Settings ▸ Face Tracking), if one is in use.
|
||||
pub face_model: Option<Arc<dyn effectcraft_segment::face::FaceModel>>,
|
||||
}
|
||||
|
||||
async fn run_work(w: MaskWork, shared: &MaskTrackShared) {
|
||||
|
|
@ -277,7 +275,7 @@ async fn run_face<F: std::future::Future<Output = Option<(Arc<effectcraft_raster
|
|||
) {
|
||||
let detailed = w.method == MaskMethod::FaceDetailed;
|
||||
let b = trk::mask::bounds(&flatten(&w.path, 8));
|
||||
let Some((mut tracker, fit)) = trk::face::FaceTracker::new_with(&trk::Frame { img: &first.0, offset: first.1 }, b, w.face_model.clone()) else {
|
||||
let Some((mut tracker, fit)) = trk::face::FaceTracker::new(&trk::Frame { img: &first.0, offset: first.1 }, b) else {
|
||||
let mut s = lock(&shared.state);
|
||||
s.error = Some("no face found inside the mask (draw the mask around a face)".into());
|
||||
s.finished = true;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ pub fn load_peaks(file: &Path) -> Option<Vec<[f32; 4]>> {
|
|||
return None;
|
||||
}
|
||||
let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
Some(b.as_chunks::<16>().0.iter().map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
|
||||
Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
|
||||
}
|
||||
|
||||
/// Store a summary (atomically: written beside, then renamed).
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ pub enum MenuNode {
|
|||
pub struct DynCtx<'a> {
|
||||
/// The current workspace (Window ▸ Assign Shortcut to "…" Workspace).
|
||||
pub workspace: Option<&'a str>,
|
||||
/// Workspaces the user saved (Save as New Workspace), listed in Window ▸ Workspace.
|
||||
pub saved_workspaces: &'a [String],
|
||||
}
|
||||
|
||||
fn file_name(p: &str) -> String {
|
||||
|
|
@ -72,9 +70,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
|
|||
|
||||
/// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
|
||||
/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
|
||||
/// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
|
||||
/// `scriptPanels` (ScriptUI panels).
|
||||
/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other
|
||||
/// than the active one), `scripts` (File ▸ Scripts) and `scriptPanels` (ScriptUI panels).
|
||||
pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
|
||||
use serde_json::json;
|
||||
let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
|
||||
|
|
@ -125,7 +122,6 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
|
|||
.collect(),
|
||||
None,
|
||||
),
|
||||
"savedWorkspaces" => (cx.saved_workspaces.iter().map(|w| dyn_entry(w.clone(), "window.workspace", json!({"name": w}))).collect(), None),
|
||||
"openViewers" => (
|
||||
s.state
|
||||
.open_comps
|
||||
|
|
@ -1050,7 +1046,6 @@ Animation
|
|||
Reveal Properties with Animation | anim.reveal {"kind":"animation"}
|
||||
Reveal All Modified Properties | anim.reveal {"kind":"modified"}
|
||||
View
|
||||
New Viewer | view.newViewer
|
||||
Split with New Locked Viewer | view.splitLockedViewer
|
||||
---
|
||||
Zoom In | view.zoomIn
|
||||
|
|
@ -1155,7 +1150,6 @@ Window
|
|||
Paint | window.workspace {"name":"Paint"}
|
||||
Text | window.workspace {"name":"Text"}
|
||||
Undocked Panels | window.workspace {"name":"Undocked Panels"}
|
||||
@dynamic:savedWorkspaces
|
||||
---
|
||||
Reset to Saved Layout | window.resetWorkspace
|
||||
Save Changes to this Workspace | window.saveWorkspace
|
||||
|
|
|
|||
|
|
@ -1,463 +0,0 @@
|
|||
//! Trained models (Settings ▸ Roto Brush and Settings ▸ Face Tracking; `roto.model*` and
|
||||
//! `face.model*` commands): which models the registry offers (`effectcraft_segment::MODELS`, all
|
||||
//! open source), which are installed (in the `models` folder), installing one from a file or
|
||||
//! downloading the official weights (verified against the registry's SHA-256 either way, and kept
|
||||
//! with a notice naming their authors and licence), choosing one per task, and loading it in the
|
||||
//! background: into the Roto Brush effect (`effects::roto::set_model`) or the face tracker
|
||||
//! ([`Models::face`]).
|
||||
//!
|
||||
//! Weights are never bundled: the install stays small and nothing is fetched unless asked for.
|
||||
//! Downloads use the system's `curl` (shipped with Windows 10+, macOS and most Linux systems),
|
||||
//! so no networking code is linked into EffectCraft; without it, the file can be downloaded in a
|
||||
//! browser and installed with Install from File. The browser build uses the built-in engines
|
||||
//! only, for now.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_segment::face::FaceModel;
|
||||
use effectcraft_segment::{self as seg, Loaded, ModelInfo, Task};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// What a task's background worker reports.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Status {
|
||||
/// What is happening now ("Downloading MobileSAM…").
|
||||
pub busy: Option<String>,
|
||||
/// The model in use.
|
||||
pub active: Option<String>,
|
||||
/// The last failure, for the Settings page.
|
||||
pub error: Option<String>,
|
||||
/// A finished job's message and whether it failed, for a toast (taken by
|
||||
/// [`Session::poll_models`]).
|
||||
pub done: Option<(String, bool)>,
|
||||
}
|
||||
|
||||
/// The session's trained models: per task, the worker's status; and the face model in use.
|
||||
#[derive(Default)]
|
||||
pub struct Models {
|
||||
roto: Arc<Mutex<Status>>,
|
||||
face: Arc<Mutex<Status>>,
|
||||
face_model: FaceSlot,
|
||||
}
|
||||
|
||||
impl Models {
|
||||
fn lane(&self, task: Task) -> &Arc<Mutex<Status>> {
|
||||
match task {
|
||||
Task::Mask => &self.roto,
|
||||
Task::Face => &self.face,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self, task: Task) -> Status {
|
||||
self.lane(task).lock().map(|s| s.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The face model face tracking uses (`None`: the classic tracker).
|
||||
pub fn face(&self) -> Option<Arc<dyn FaceModel>> {
|
||||
self.face_model.lock().ok().and_then(|m| m.clone())
|
||||
}
|
||||
|
||||
fn set_active(&self, task: Task, model: Option<Loaded>) {
|
||||
activate(self.lane(task), &self.face_model, task, model);
|
||||
}
|
||||
}
|
||||
|
||||
type FaceSlot = Arc<Mutex<Option<Arc<dyn FaceModel>>>>;
|
||||
|
||||
/// Put a loaded model to use (or drop `task`'s): Roto Brush's lives in the effect, the face
|
||||
/// tracker's in `face`.
|
||||
fn activate(lane: &Arc<Mutex<Status>>, face: &FaceSlot, task: Task, model: Option<Loaded>) {
|
||||
let id = model.as_ref().map(|m| match m {
|
||||
Loaded::Mask(m) => m.info().id.to_string(),
|
||||
Loaded::Face(m) => m.info().id.to_string(),
|
||||
});
|
||||
match (task, model) {
|
||||
(Task::Mask, Some(Loaded::Mask(m))) => crate::effects::roto::set_model(Some(m)),
|
||||
(Task::Mask, _) => crate::effects::roto::set_model(None),
|
||||
(Task::Face, m) => {
|
||||
if let Ok(mut slot) = face.lock() {
|
||||
*slot = match m {
|
||||
Some(Loaded::Face(f)) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
set(lane, |s| s.active = id);
|
||||
}
|
||||
|
||||
fn set(status: &Arc<Mutex<Status>>, f: impl FnOnce(&mut Status)) {
|
||||
if let Ok(mut s) = status.lock() {
|
||||
f(&mut s);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a task shows in Settings, toasts and the command list.
|
||||
struct Kind {
|
||||
/// Command prefix and settings key section.
|
||||
prefix: &'static str,
|
||||
label: &'static str,
|
||||
classic_name: &'static str,
|
||||
classic_description: &'static str,
|
||||
}
|
||||
|
||||
const ROTO: Kind = Kind {
|
||||
prefix: "roto",
|
||||
label: "Roto Brush",
|
||||
classic_name: "Classic (graph cut)",
|
||||
classic_description: "Built in: colour models and graph cuts, propagated by optical flow. Always available.",
|
||||
};
|
||||
|
||||
const FACE: Kind = Kind {
|
||||
prefix: "face",
|
||||
label: "Face Tracking",
|
||||
classic_name: "Classic (shape model)",
|
||||
classic_description: "Built in: a skin colour model, facial feature shapes and a fitted face shape model. Best on front-facing, evenly lit faces. Always available.",
|
||||
};
|
||||
|
||||
fn kind(task: Task) -> &'static Kind {
|
||||
match task {
|
||||
Task::Mask => &ROTO,
|
||||
Task::Face => &FACE,
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The folder installed models live in: the host's, else `models` next to the settings.
|
||||
pub fn models_dir(&self) -> Option<PathBuf> {
|
||||
self.models_dir.clone().or_else(|| Some(self.config.as_ref()?.dir()?.join("models")))
|
||||
}
|
||||
|
||||
/// Where `m` is installed, if it is (a file of the published size).
|
||||
pub fn model_path(&self, m: &ModelInfo) -> Option<PathBuf> {
|
||||
let p = self.models_dir()?.join(m.file_name);
|
||||
std::fs::metadata(&p).ok().filter(|md| md.len() == m.size).map(|_| p)
|
||||
}
|
||||
|
||||
/// The model chosen for `task` (Settings).
|
||||
pub fn chosen_model(&self, task: Task) -> &str {
|
||||
match task {
|
||||
Task::Mask => &self.prefs.roto.model,
|
||||
Task::Face => &self.prefs.face.model,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_model(&mut self, task: Task, id: &str) {
|
||||
let slot = match task {
|
||||
Task::Mask => &mut self.prefs.roto.model,
|
||||
Task::Face => &mut self.prefs.face.model,
|
||||
};
|
||||
*slot = id.to_string();
|
||||
}
|
||||
|
||||
/// Load (or drop) the models Settings chooses; called whenever settings change.
|
||||
pub fn apply_models(&mut self) {
|
||||
for task in [Task::Mask, Task::Face] {
|
||||
self.apply_model(task, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load (or drop) `task`'s chosen model; `wait`: before returning (scripts and agents).
|
||||
fn apply_model(&mut self, task: Task, wait: bool) {
|
||||
let want = self.chosen_model(task).to_string();
|
||||
let lane = self.models.lane(task).clone();
|
||||
let st = self.models.status(task);
|
||||
let info = seg::info(&want).filter(|m| m.task == task);
|
||||
let Some(info) = info else {
|
||||
if st.active.is_some() {
|
||||
self.models.set_active(task, None);
|
||||
}
|
||||
return;
|
||||
};
|
||||
if st.active.as_deref() == Some(info.id) || st.busy.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.model_path(info) else {
|
||||
self.models.set_active(task, None);
|
||||
return;
|
||||
};
|
||||
let (face_slot, label) = (self.models.face_model.clone(), kind(task).label);
|
||||
spawn(&lane, label, format!("Loading {}…", info.name), wait, move |status| {
|
||||
let m = load_file(&path, info.id)?;
|
||||
activate(status, &face_slot, task, Some(m));
|
||||
Ok(format!("{label}: {} loaded", info.name))
|
||||
});
|
||||
}
|
||||
|
||||
/// Finish what the model workers did: a toast, and loading a model that just arrived.
|
||||
pub fn poll_models(&mut self) {
|
||||
for task in [Task::Mask, Task::Face] {
|
||||
let done = self.models.lane(task).lock().ok().and_then(|mut s| s.done.take());
|
||||
if let Some((message, error)) = done {
|
||||
self.events.push(crate::Event::Toast { message, error });
|
||||
self.apply_model(task, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file(path: &Path, id: &str) -> std::result::Result<Loaded, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
seg::load(id, &bytes)
|
||||
}
|
||||
|
||||
/// Run `job` on a worker thread (`wait`: right here), reporting through `status` (desktop; the
|
||||
/// browser build has no trained models yet).
|
||||
fn spawn(
|
||||
status: &Arc<Mutex<Status>>,
|
||||
label: &'static str,
|
||||
what: String,
|
||||
wait: bool,
|
||||
job: impl FnOnce(&Arc<Mutex<Status>>) -> std::result::Result<String, String> + Send + 'static,
|
||||
) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
set(status, |s| s.error = Some("trained models are available in the desktop app".into()));
|
||||
return;
|
||||
}
|
||||
set(status, |s| {
|
||||
s.busy = Some(what);
|
||||
s.error = None;
|
||||
});
|
||||
let st = status.clone();
|
||||
let run = move || {
|
||||
let r = job(&st);
|
||||
set(&st, |s| {
|
||||
s.busy = None;
|
||||
match r {
|
||||
Ok(msg) => s.done = Some((msg, false)),
|
||||
Err(e) => {
|
||||
s.done = Some((format!("{label} model: {e}"), true));
|
||||
s.error = Some(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
if wait {
|
||||
run();
|
||||
} else if let Err(e) = std::thread::Builder::new().name("ec-model".into()).spawn(run) {
|
||||
set(status, |s| {
|
||||
s.busy = None;
|
||||
s.error = Some(format!("cannot start: {e}"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The notice kept next to `m`'s weights.
|
||||
fn notice_path(dir: &Path, m: &ModelInfo) -> PathBuf {
|
||||
dir.join(format!("{}.NOTICE.txt", m.file_name))
|
||||
}
|
||||
|
||||
/// Copy verified weights into the models folder (atomically), with their notice.
|
||||
fn install_bytes(dir: &Path, m: &ModelInfo, bytes: &[u8]) -> std::result::Result<PathBuf, String> {
|
||||
if bytes.len() as u64 != m.size || seg::sha256::hex(bytes) != m.sha256 {
|
||||
return Err(format!("this is not the published {} file (size or SHA-256 differs)", m.name));
|
||||
}
|
||||
use crate::config::FileOps;
|
||||
let path = dir.join(m.file_name);
|
||||
let files = crate::config::StdFiles;
|
||||
files.write(&path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
files.write(¬ice_path(dir, m), seg::notice(m).as_bytes()).map_err(|e| format!("cannot write the notice: {e}"))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn model_p(p: &Value, cmd: &str, task: Task) -> Result<&'static ModelInfo> {
|
||||
let id = str_p(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
seg::info(id).filter(|m| m.task == task).ok_or_else(|| bad(cmd, format!("unknown model `{id}` (see {}.models)", kind(task).prefix)))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, task: Task) -> Result<Value> {
|
||||
let k = kind(task);
|
||||
let st = s.models.status(task);
|
||||
let chosen = s.chosen_model(task).to_string();
|
||||
let mut v = vec![json!({
|
||||
"id": seg::CLASSICAL,
|
||||
"name": k.classic_name,
|
||||
"description": k.classic_description,
|
||||
"licence": "MIT OR Apache-2.0",
|
||||
"installed": true,
|
||||
"selected": chosen == seg::CLASSICAL,
|
||||
"active": st.active.is_none(),
|
||||
})];
|
||||
for m in seg::models(task) {
|
||||
v.push(json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"authors": m.authors,
|
||||
"licence": m.licence,
|
||||
"licenceUrl": m.licence_url,
|
||||
"homepage": m.homepage,
|
||||
"url": m.url,
|
||||
"size": m.size,
|
||||
"sha256": m.sha256,
|
||||
"installed": s.model_path(m).is_some(),
|
||||
"selected": chosen == m.id,
|
||||
"active": st.active.as_deref() == Some(m.id),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"models": v, "busy": st.busy, "error": st.error, "folder": s.models_dir().map(|d| d.display().to_string())}))
|
||||
}
|
||||
|
||||
/// After a `wait`: the job's failure, as the command's.
|
||||
fn waited(s: &Session, task: Task, cmd: &str) -> Result<()> {
|
||||
match s.models.status(task).error {
|
||||
Some(e) => Err(bad(cmd, e)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
let cmd = format!("{}.model.select", kind(task).prefix);
|
||||
let id = str_p(p, "id").ok_or_else(|| bad(&cmd, "missing `id`"))?;
|
||||
if id != seg::CLASSICAL {
|
||||
model_p(p, &cmd, task)?;
|
||||
}
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
s.choose_model(task, id);
|
||||
if wait {
|
||||
s.apply_model(task, true);
|
||||
waited(s, task, &cmd)?;
|
||||
}
|
||||
s.save_prefs();
|
||||
s.prefs_changed();
|
||||
Ok(json!({"model": id, "active": s.models.status(task).active}))
|
||||
}
|
||||
|
||||
fn install(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
let cmd = format!("{}.model.install", kind(task).prefix);
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(&cmd, "missing `path` (the downloaded weights file)"))?;
|
||||
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
// Which model it is: the one asked for, or the one whose checksum it has.
|
||||
let m = match str_p(p, "id") {
|
||||
Some(_) => model_p(p, &cmd, task)?,
|
||||
None => {
|
||||
let h = seg::sha256::hex(&bytes);
|
||||
seg::models(task).find(|m| m.sha256 == h).ok_or_else(|| bad(&cmd, "this file is not a published model in the registry (SHA-256 differs)"))?
|
||||
}
|
||||
};
|
||||
let dest = install_bytes(&dir, m, &bytes).map_err(|e| bad(&cmd, e))?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
s.apply_model(task, wait);
|
||||
if wait {
|
||||
waited(s, task, &cmd)?;
|
||||
}
|
||||
Ok(json!({"id": m.id, "path": dest.display().to_string()}))
|
||||
}
|
||||
|
||||
fn download(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
let k = kind(task);
|
||||
let cmd = format!("{}.model.download", k.prefix);
|
||||
let m = model_p(p, &cmd, task)?;
|
||||
let dir = s.models_dir().ok_or_else(|| bad(&cmd, "no settings folder to install into"))?;
|
||||
if s.models.status(task).busy.is_some() {
|
||||
return Err(bad(&cmd, "a model is already downloading or loading"));
|
||||
}
|
||||
let size = if m.size >= 10_000_000 { format!("{} MB", m.size / 1_000_000) } else { format!("{:.1} MB", m.size as f64 / 1e6) };
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
spawn(s.models.lane(task), k.label, format!("Downloading {} ({size})…", m.name), wait, move |_| {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
let tmp = dir.join(format!("{}.part", m.file_name));
|
||||
let out = std::process::Command::new("curl")
|
||||
.args(["-fsSL", "--retry", "2", "--connect-timeout", "20", "-o"])
|
||||
.arg(&tmp)
|
||||
.arg(m.url)
|
||||
.output()
|
||||
.map_err(|e| format!("downloading needs curl ({e}); or download {} in a browser and use Install from File", m.url))?;
|
||||
if !out.status.success() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(format!("download failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
let bytes = std::fs::read(&tmp).map_err(|e| format!("cannot read the download: {e}"))?;
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
install_bytes(&dir, m, &bytes)?;
|
||||
Ok(format!("{}: {} downloaded and verified", k.label, m.name))
|
||||
});
|
||||
if wait {
|
||||
waited(s, task, &cmd)?;
|
||||
s.apply_model(task, true);
|
||||
waited(s, task, &cmd)?;
|
||||
return Ok(json!({"id": m.id, "installed": true}));
|
||||
}
|
||||
Ok(json!({"id": m.id, "downloading": true}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value, task: Task) -> Result<Value> {
|
||||
let m = model_p(p, &format!("{}.model.remove", kind(task).prefix), task)?;
|
||||
let Some(path) = s.model_path(m) else { return Ok(json!({"removed": false})) };
|
||||
if s.models.status(task).active.as_deref() == Some(m.id) {
|
||||
s.models.set_active(task, None);
|
||||
}
|
||||
std::fs::remove_file(&path).map_err(|e| EngineError::Other(format!("cannot remove {}: {e}", path.display())))?;
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::remove_file(notice_path(dir, m));
|
||||
}
|
||||
Ok(json!({"removed": true}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("roto.models", "Roto Brush Models", "{}", |s, _| list(s, Task::Mask)),
|
||||
cmd!(
|
||||
"roto.model.select",
|
||||
"Use Roto Brush Model",
|
||||
[],
|
||||
None,
|
||||
"{id: classical|mobilesam, wait?} (Roto Brush 2.0 / 3.0 use it; wait: loaded before returning)",
|
||||
always,
|
||||
|s, p| select(s, p, Task::Mask)
|
||||
),
|
||||
cmd!(
|
||||
"roto.model.install",
|
||||
"Install Roto Brush Model",
|
||||
[],
|
||||
None,
|
||||
"{path: weights file, id?, wait?} — verified against the registry's SHA-256",
|
||||
always,
|
||||
|s, p| install(s, p, Task::Mask)
|
||||
),
|
||||
cmd!(
|
||||
"roto.model.download",
|
||||
"Download Roto Brush Model",
|
||||
[],
|
||||
None,
|
||||
"{id, wait?} — the official weights, verified (desktop; uses the system curl)",
|
||||
always,
|
||||
|s, p| download(s, p, Task::Mask)
|
||||
),
|
||||
cmd!("roto.model.remove", "Remove Roto Brush Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Mask)),
|
||||
query!("face.models", "Face Tracking Models", "{}", |s, _| list(s, Task::Face)),
|
||||
cmd!(
|
||||
"face.model.select",
|
||||
"Use Face Tracking Model",
|
||||
[],
|
||||
None,
|
||||
"{id: classical|mediapipe-face, wait?} (face tracking uses it; wait: loaded before returning)",
|
||||
always,
|
||||
|s, p| select(s, p, Task::Face)
|
||||
),
|
||||
cmd!(
|
||||
"face.model.install",
|
||||
"Install Face Tracking Model",
|
||||
[],
|
||||
None,
|
||||
"{path: model file, id?, wait?} — verified against the registry's SHA-256",
|
||||
always,
|
||||
|s, p| install(s, p, Task::Face)
|
||||
),
|
||||
cmd!(
|
||||
"face.model.download",
|
||||
"Download Face Tracking Model",
|
||||
[],
|
||||
None,
|
||||
"{id, wait?} — the official model, verified (desktop; uses the system curl)",
|
||||
always,
|
||||
|s, p| download(s, p, Task::Face)
|
||||
),
|
||||
cmd!("face.model.remove", "Remove Face Tracking Model", [], None, "{id}", always, |s, p| remove(s, p, Task::Face)),
|
||||
]
|
||||
}
|
||||
|
|
@ -519,7 +519,6 @@ async fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), Stri
|
|||
path,
|
||||
method,
|
||||
times,
|
||||
face_model: if method.is_face() { s.models.face() } else { None },
|
||||
};
|
||||
s.start_mask_track(work, mask, direction, true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ macro_rules! page {
|
|||
}
|
||||
|
||||
page!(General {
|
||||
/// Interface language (`en` or `ja`).
|
||||
language: String = "en".into(),
|
||||
/// Levels of Undo (1–99).
|
||||
undo_levels: u32 = 32,
|
||||
/// Path Point and Handle Size (px).
|
||||
|
|
@ -211,24 +209,6 @@ page!(ThreeD {
|
|||
realtime_shadows: bool = true,
|
||||
});
|
||||
|
||||
page!(
|
||||
/// Settings ▸ Roto Brush.
|
||||
RotoPrefs {
|
||||
/// The segmentation model Roto Brush 2.0 / 3.0 use: `classical` (built in) or a model id
|
||||
/// from `effectcraft_segment::MODELS` (once installed).
|
||||
model: String = "classical".into(),
|
||||
}
|
||||
);
|
||||
|
||||
page!(
|
||||
/// Settings ▸ Face Tracking.
|
||||
FacePrefs {
|
||||
/// The model Track Mask ▸ Face Tracking uses: `classical` (built in) or a model id from
|
||||
/// `effectcraft_segment::MODELS` (once installed).
|
||||
model: String = "classical".into(),
|
||||
}
|
||||
);
|
||||
|
||||
page!(Scripting {
|
||||
allow_scripts_write_files: bool = false,
|
||||
warn_executing_files: bool = true,
|
||||
|
|
@ -262,7 +242,7 @@ fn parse_hex(s: &str) -> Option<[u8; 3]> {
|
|||
if h.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let p = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
|
||||
let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
|
||||
Some([p(0)?, p(2)?, p(4)?])
|
||||
}
|
||||
|
||||
|
|
@ -292,8 +272,6 @@ pub struct Prefs {
|
|||
#[serde(rename = "threeD")]
|
||||
pub three_d: ThreeD,
|
||||
pub scripting: Scripting,
|
||||
pub roto: RotoPrefs,
|
||||
pub face: FacePrefs,
|
||||
/// File ▸ Open Recent, newest first.
|
||||
pub recent_projects: Vec<String>,
|
||||
/// File ▸ Import Recent Footage, newest first.
|
||||
|
|
@ -334,8 +312,6 @@ impl Default for Prefs {
|
|||
video: Video::default(),
|
||||
three_d: ThreeD::default(),
|
||||
scripting: Scripting::default(),
|
||||
roto: RotoPrefs::default(),
|
||||
face: FacePrefs::default(),
|
||||
recent_projects: vec![],
|
||||
recent_footage: vec![],
|
||||
recent_presets: vec![],
|
||||
|
|
@ -417,9 +393,6 @@ impl Prefs {
|
|||
pub fn normalize(&mut self) {
|
||||
self.version = PREFS_VERSION;
|
||||
let g = &mut self.general;
|
||||
if !matches!(g.language.as_str(), "en" | "ja") {
|
||||
g.language = "en".into();
|
||||
}
|
||||
g.undo_levels = g.undo_levels.clamp(1, 99);
|
||||
g.path_point_size = g.path_point_size.clamp(3, 20);
|
||||
g.recent_items = g.recent_items.clamp(1, 30);
|
||||
|
|
@ -458,9 +431,6 @@ impl Prefs {
|
|||
|
||||
/// Set the value at a dotted key. The key must exist and the value must have its type.
|
||||
pub fn set(&mut self, key: &str, value: Value) -> Result<(), String> {
|
||||
if key == "general.language" && !matches!(value.as_str(), Some("en" | "ja")) {
|
||||
return Err("`general.language` expects `en` or `ja`".into());
|
||||
}
|
||||
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
|
||||
let ptr = format!("/{}", key.replace('.', "/"));
|
||||
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
|
||||
|
|
@ -739,8 +709,6 @@ pub fn section_key(page: &str) -> Option<&'static str> {
|
|||
"video" => "video",
|
||||
"3d" | "threeD" => "threeD",
|
||||
"scripting" => "scripting",
|
||||
"roto" => "roto",
|
||||
"face" => "face",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -767,8 +735,6 @@ pub fn page_id(name: &str) -> Option<&'static str> {
|
|||
"video" | "videopreview" => "video",
|
||||
"3d" | "threed" => "3d",
|
||||
"scripting" | "scriptingexpressions" => "scripting",
|
||||
"roto" | "rotobrush" | "models" => "roto",
|
||||
"face" | "facetracking" => "face",
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
|
|
@ -856,9 +822,6 @@ pub enum Item {
|
|||
/// The browser's storage manager (web app only, `storage.*`): usage and quota, persistent
|
||||
/// storage, Clear buttons. Not shown where there is no browser storage.
|
||||
BrowserStorage,
|
||||
/// A task's trained models (`roto.models`, `face.models`): authors, licence, size, install,
|
||||
/// choose.
|
||||
Models(effectcraft_segment::Task),
|
||||
}
|
||||
|
||||
/// One page of the Settings dialog.
|
||||
|
|
@ -885,7 +848,6 @@ pub fn pages() -> Vec<Page> {
|
|||
id: "general",
|
||||
title: "General",
|
||||
items: vec![
|
||||
s("general.language", "Language", Kind::Choice(&[("English", "en"), ("日本語", "ja")]), true),
|
||||
s("general.undoLevels", "Levels of Undo", Kind::Int(1, 99, ""), true),
|
||||
s("general.pathPointSize", "Path Point and Handle Size", Kind::Int(3, 20, "px"), true),
|
||||
s("general.recentItems", "Recent Projects Shown", Kind::Int(1, 30, ""), true),
|
||||
|
|
@ -1134,28 +1096,6 @@ pub fn pages() -> Vec<Page> {
|
|||
Note("Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger."),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "roto",
|
||||
title: "Roto Brush",
|
||||
items: vec![
|
||||
Section("Segmentation Model"),
|
||||
Note(
|
||||
"Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
),
|
||||
Models(effectcraft_segment::Task::Mask),
|
||||
],
|
||||
},
|
||||
Page {
|
||||
id: "face",
|
||||
title: "Face Tracking",
|
||||
items: vec![
|
||||
Section("Face Model"),
|
||||
Note(
|
||||
"Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.",
|
||||
),
|
||||
Models(effectcraft_segment::Task::Face),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1208,10 +1148,6 @@ pub fn pages_json() -> Value {
|
|||
Item::AudioDevices { key } => json!({"type": "device", "key": key, "label": "Default Output"}),
|
||||
Item::Note(t) => json!({"note": t}),
|
||||
Item::BrowserStorage => json!({"browserStorage": "storage.info / storage.persist / storage.clear (web app)"}),
|
||||
Item::Models(task) => {
|
||||
let p = if *task == effectcraft_segment::Task::Face { "face" } else { "roto" };
|
||||
json!({"models": format!("{p}.models / {p}.model.select / {p}.model.download / {p}.model.install / {p}.model.remove"), "key": format!("{p}.model")})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
json!({"id": p.id, "title": p.title, "items": items})
|
||||
|
|
|
|||
|
|
@ -640,11 +640,11 @@ impl Session {
|
|||
}
|
||||
|
||||
/// The comp to add: `comp` param, the active comp, or the first comp selected in the Project panel.
|
||||
pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> crate::Result<ItemId> {
|
||||
pub(crate) fn comp_for_queue(&self, p: &serde_json::Value) -> Option<ItemId> {
|
||||
if p.get("comp").is_some() {
|
||||
return crate::commands::comp_id(self, p);
|
||||
return crate::commands::comp_id(self, p).ok();
|
||||
}
|
||||
let sel = self.state.project_selection.iter().copied().find(|i| self.project.comp(*i).is_some());
|
||||
self.active_comp_id().or(sel).ok_or(crate::EngineError::NoComp)
|
||||
self.active_comp_id().or(sel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -636,7 +636,6 @@ impl Session {
|
|||
/// Frontends call this every frame: finishes jobs and (with `auto`) propagates edited
|
||||
/// instances in the background.
|
||||
pub fn poll_roto(&mut self, auto: bool) -> bool {
|
||||
self.poll_models();
|
||||
let changed = self.poll_roto_job();
|
||||
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
|
||||
while let Some((c, l, e)) = self.roto_pending.first().copied() {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ fn add_configure_render() {
|
|||
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();
|
||||
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!(r["outputPath"], "/tmp/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 c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
|
||||
|
|
@ -77,14 +77,11 @@ fn add_configure_render() {
|
|||
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
|
||||
let r = s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
|
||||
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
|
||||
);
|
||||
assert_eq!(*ex.log.lock().unwrap(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]);
|
||||
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!(q[0].last_output.as_deref(), Some("/tmp/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, .. })));
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ impl Session {
|
|||
let levels = self.prefs.general.undo_levels.max(1) as usize;
|
||||
let current = self.project.clone();
|
||||
self.history.trim(levels, ¤t);
|
||||
// Settings ▸ Roto Brush / Face Tracking: load (or drop) the chosen trained models.
|
||||
self.apply_models();
|
||||
self.prefs_revision += 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,16 +101,6 @@ pub fn parse_vm_stat(text: &str, total: u64) -> Option<SysMemory> {
|
|||
Some(SysMemory { total, available: (free * page).min(total) })
|
||||
}
|
||||
|
||||
/// Parse `sysctl -n hw.physmem hw.pagesize vm.stats.vm.v_free_count vm.stats.vm.v_inactive_count`
|
||||
/// (FreeBSD: one number per line, in that order).
|
||||
pub fn parse_freebsd_sysctl(text: &str) -> Option<SysMemory> {
|
||||
let mut it = text.split_whitespace().map(|v| v.parse::<u64>().ok());
|
||||
let total = it.next()??;
|
||||
let page = it.next()??;
|
||||
let free = it.next()??.saturating_add(it.next()??);
|
||||
Some(SysMemory { total, available: free.saturating_mul(page).min(total) })
|
||||
}
|
||||
|
||||
/// Total and available physical memory, when the system reports them.
|
||||
pub fn memory() -> Option<SysMemory> {
|
||||
#[cfg(target_os = "linux")]
|
||||
|
|
@ -122,10 +112,6 @@ pub fn memory() -> Option<SysMemory> {
|
|||
let total: u64 = run("sysctl", &["-n", "hw.memsize"])?.trim().parse().ok()?;
|
||||
return parse_vm_stat(&run("vm_stat", &[])?, total).or(Some(SysMemory { total, available: total }));
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
return parse_freebsd_sysctl(&run("sysctl", &["-n", "hw.physmem", "hw.pagesize", "vm.stats.vm.v_free_count", "vm.stats.vm.v_inactive_count"])?);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let text = run(
|
||||
|
|
@ -150,10 +136,6 @@ pub fn cpu_name() -> Option<String> {
|
|||
{
|
||||
return run("sysctl", &["-n", "machdep.cpu.brand_string"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
{
|
||||
return run("sysctl", &["-n", "hw.model"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return std::env::var("PROCESSOR_IDENTIFIER").ok();
|
||||
|
|
@ -174,10 +156,6 @@ pub fn os_version() -> String {
|
|||
{
|
||||
return n.trim_matches('"').to_string();
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
if let Some(v) = run("freebsd-version", &[]) {
|
||||
return format!("FreeBSD {}", v.trim());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(v) = run("cmd", &["/C", "ver"]) {
|
||||
return v.trim().to_string();
|
||||
|
|
@ -194,18 +172,6 @@ pub fn cpu_cores() -> usize {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn freebsd_sysctl_memory_parses() {
|
||||
let m = parse_freebsd_sysctl("17179869184\n4096\n1000000\n500000\n").expect("parses");
|
||||
assert_eq!(m.total, 17_179_869_184);
|
||||
assert_eq!(m.available, 1_500_000 * 4096);
|
||||
assert!(parse_freebsd_sysctl("17179869184\n4096\n").is_none());
|
||||
assert!(parse_freebsd_sysctl("garbage").is_none());
|
||||
// Available never exceeds total, even with nonsense counts.
|
||||
let m = parse_freebsd_sysctl("1000\n4096\n999999999\n999999999").expect("parses");
|
||||
assert_eq!(m.available, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_proc_meminfo_and_vm_stat() {
|
||||
let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap();
|
||||
|
|
|
|||
|
|
@ -716,14 +716,15 @@ fn footage_dir(s: &Session, name: &str, footage_dir: Option<&str>, project_path:
|
|||
}
|
||||
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_()".contains(c) { c } else { '_' }).collect();
|
||||
let safe = if safe.trim().is_empty() { "Template".to_string() } else { safe.trim().to_string() };
|
||||
let base = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(FOOTAGE_DIR));
|
||||
let base = match s.config.as_ref().and_then(|c| c.dir()) {
|
||||
Some(d) => d.join(FOOTAGE_DIR),
|
||||
None => PathBuf::from(format!("/{FOOTAGE_DIR}")),
|
||||
};
|
||||
// A fresh folder per created project.
|
||||
let mut k = 1;
|
||||
loop {
|
||||
let folder = if k == 1 { safe.clone() } else { format!("{safe} {k}") };
|
||||
// Virtual storage keys use '/' independently of the host OS. Native settings
|
||||
// folders retain their platform path semantics.
|
||||
let ds = base.as_ref().map(|d| d.join(&folder).to_string_lossy().into_owned()).unwrap_or_else(|| format!("/{FOOTAGE_DIR}/{folder}"));
|
||||
let d = base.join(if k == 1 { safe.clone() } else { format!("{safe} {k}") });
|
||||
let ds = d.to_string_lossy().into_owned();
|
||||
if !s.services.exists(&ds) || k > 999 {
|
||||
return ds;
|
||||
}
|
||||
|
|
@ -954,7 +955,7 @@ mod tests {
|
|||
assert!(t.width > 0 && t.duration > 0.0, "{t:?}");
|
||||
let th = decode_thumb(&thumbnail(&s, &t.id).unwrap()).unwrap();
|
||||
// Not a blank frame: some variety in the pixels.
|
||||
let distinct: std::collections::BTreeSet<[u8; 3]> = th.as_chunks::<3>().0.iter().map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect();
|
||||
let distinct: std::collections::BTreeSet<[u8; 3]> = th.chunks_exact(3).map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect();
|
||||
assert!(distinct.len() > 3, "{}: thumbnail looks blank", t.id);
|
||||
}
|
||||
let sq = l.iter().find(|t| t.id == "social-square").unwrap();
|
||||
|
|
@ -1120,7 +1121,7 @@ mod tests {
|
|||
let dir = root.join("work").join("Promo Footage");
|
||||
let fp = footage_paths(&s);
|
||||
let get = |n: &str| fp.iter().find(|f| f.0 == n).unwrap().clone();
|
||||
assert_eq!(std::path::Path::new(&get("Still").1), dir.join("still.png"));
|
||||
assert_eq!(get("Still").1, dir.join("still.png").to_string_lossy());
|
||||
assert_eq!(std::fs::read(dir.join("still.png")).unwrap(), vec![1u8; 1000]);
|
||||
let shot = get("Shot");
|
||||
assert_eq!(shot.2.len(), 3);
|
||||
|
|
|
|||
|
|
@ -47,76 +47,6 @@ fn demo_layer_cache_is_transparent() {
|
|||
}
|
||||
}
|
||||
|
||||
/// `layer.addItem` puts the layer at `index` (1-based) in the stack and starts it at `time`, as a
|
||||
/// drop in the Timeline does (#89).
|
||||
#[test]
|
||||
fn add_item_at_a_stack_index_and_time() {
|
||||
let mut s = Session::default();
|
||||
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 16, "height": 16, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 30, "duration": 4})).unwrap();
|
||||
for name in ["C", "B", "A"] {
|
||||
s.execute("layer.newSolid", json!({"name": name})).unwrap();
|
||||
}
|
||||
let names = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||||
s.execute("layer.addItem", json!({"item": clip, "index": 2, "time": 1.01})).unwrap();
|
||||
assert_eq!(names(&s), ["A", "Clip", "B", "C"]);
|
||||
let l = &s.active_comp().unwrap().layers[1];
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0), "on the nearest frame");
|
||||
// Past the bottom: the bottom.
|
||||
s.execute("layer.addItem", json!({"item": clip, "index": 99})).unwrap();
|
||||
assert_eq!(names(&s).last().map(String::as_str), Some("Clip 2"));
|
||||
assert!(s.execute("layer.addItem", json!({"item": clip, "index": 0})).is_err(), "1-based");
|
||||
}
|
||||
|
||||
/// Files dropped on the Timeline or the Composition viewer (`file.import {addToComp}`) become
|
||||
/// layers of the comp that was active, one under the other, where they were dropped (#85, #89).
|
||||
#[test]
|
||||
fn imported_files_go_into_the_comp_where_they_were_dropped() {
|
||||
use effectcraft_project::{Footage, FootageKind};
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bg"})).unwrap();
|
||||
let params = json!({"paths": ["/drop/a.png", "/drop/b.png"], "addToComp": true, "position": [40.0, 50.0], "index": 1, "time": 1.0});
|
||||
s.execute("file.import", params).unwrap();
|
||||
let comp = s.active_comp().unwrap();
|
||||
assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["a.png", "b.png", "Bg"]);
|
||||
for l in &comp.layers[..2] {
|
||||
assert_eq!(l.props.prop("transform/position").map(|p| p.value.clone()), Some(effectcraft_keyframe::Value::Vec3([40.0, 50.0, 0.0])));
|
||||
assert_eq!(l.in_point, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
}
|
||||
// Without `addToComp` they only import.
|
||||
s.execute("file.import", json!({"paths": ["/drop/c.png"]})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
|
||||
assert!(s.execute("layer.addItem", json!({"item": "a.png", "position": [1.0]})).is_err(), "position needs x and y");
|
||||
}
|
||||
|
||||
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
|
||||
#[test]
|
||||
fn audio_lock_and_shy_keep_cached_layers() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = s.time();
|
||||
s.render(cid, t, Default::default());
|
||||
let before = s.layer_cache.stats();
|
||||
for sw in ["audio", "lock", "shy"] {
|
||||
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": sw, "value": sw != "audio"})).unwrap();
|
||||
}
|
||||
s.render(cid, t, Default::default());
|
||||
let after = s.layer_cache.stats();
|
||||
assert_eq!(after.misses, before.misses, "no layer rendered again");
|
||||
assert!(after.hits > before.hits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_runs_or_reports() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
|
|
@ -183,71 +113,6 @@ fn text_shape_mask_matte_parent() {
|
|||
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn
|
||||
/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include
|
||||
/// an empty Path and a Gradient Stroke that render.
|
||||
#[test]
|
||||
fn toolbar_fill_and_stroke_paint_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 paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke");
|
||||
s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}");
|
||||
s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}");
|
||||
// One stroke, before the fill (paths, stroke, fill), and each change is one undo step.
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
|
||||
let group = l.props.group("contents/group").unwrap();
|
||||
let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
|
||||
assert_eq!(order, ["rect", "stroke", "fill"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0));
|
||||
// Only shape layers.
|
||||
s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap();
|
||||
assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err());
|
||||
for kind in ["path", "gstroke"] {
|
||||
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap();
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}");
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn selecting_a_mask_selects_its_points() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap();
|
||||
let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap();
|
||||
let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count();
|
||||
s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap();
|
||||
assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4));
|
||||
// Mask Path selects them too; Shift-adding another mask keeps the first.
|
||||
let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap();
|
||||
assert_eq!((points(&s, m1), points(&s, m2)), (4, 4));
|
||||
// Another mask replaces them; another property deselects them.
|
||||
s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap();
|
||||
assert_eq!((points(&s, m1), points(&s, m2)), (0, 4));
|
||||
// Delete removes the selected mask (not only its points).
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len();
|
||||
assert_eq!(masks(&s), 1);
|
||||
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
|
||||
assert!(s.state.selected_vertices.is_empty());
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap();
|
||||
assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_roundtrip() {
|
||||
let mut s = demo();
|
||||
|
|
@ -436,27 +301,6 @@ fn comp_settings_anchor_start_timecode_renderer() {
|
|||
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
|
||||
}
|
||||
|
||||
/// `advanced3D` is a value of `renderer`, not a key: comp.new / comp.settings don't advertise it
|
||||
/// in their schemas and reject it as unknown instead of ignoring it (#177).
|
||||
#[test]
|
||||
fn renderer_values_are_not_parameter_keys() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [("comp.new", json!({"name": "A"})), ("comp.settings", json!({}))] {
|
||||
let spec = crate::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
let keys = crate::commands::accepted_params(spec.params).unwrap();
|
||||
assert!(keys.contains(&"renderer".to_string()) && !keys.contains(&"advanced3D".to_string()), "{id}: {keys:?}");
|
||||
let mut bad = p.clone();
|
||||
bad["advanced3D"] = json!(true);
|
||||
let e = s.execute_checked(id, bad).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter(s) `advanced3D`"), "{id}: {e}");
|
||||
let mut good = p;
|
||||
good["renderer"] = json!("advanced3D");
|
||||
s.execute_checked(id, good).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, crate::project::Renderer::Advanced3D, "{id}");
|
||||
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_rejects_zero_and_negative_frame_rates() {
|
||||
let mut s = Session::default();
|
||||
|
|
|
|||
|
|
@ -51,95 +51,3 @@ fn hevc_and_av1_output_module_options() {
|
|||
assert!(f["hevcLevels"].as_array().unwrap().contains(&json!("4.1")));
|
||||
assert_eq!(f["webmCodecs"], json!(["vp9", "av1"]));
|
||||
}
|
||||
|
||||
/// An explicit format wins over the output file's extension, which follows it (#154:
|
||||
/// `effectcraft-cli render --format hevc --out x.mp4` wrote H.264). Without a format, the extension
|
||||
/// still picks one.
|
||||
#[test]
|
||||
fn an_explicit_format_wins_over_the_output_extension() {
|
||||
let mut s = session();
|
||||
let cases = [
|
||||
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
|
||||
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
|
||||
("hevc", "/out/x.mov", OutputFormat::Hevc, "/out/x.mp4"),
|
||||
("av1", "/out/x.webm", OutputFormat::Av1, "/out/x.mp4"),
|
||||
("prores", "/out/x.mp4", OutputFormat::ProRes, "/out/x.mov"),
|
||||
];
|
||||
for (k, (format, out, want, path)) in cases.into_iter().enumerate() {
|
||||
s.execute("renderQueue.add", json!({"format": format, "output": out})).unwrap();
|
||||
let om = &s.project.render_queue[k].output;
|
||||
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
|
||||
// A second output module of the item behaves the same.
|
||||
s.execute("render.addOutputModule", json!({"index": k + 1, "format": format, "output": out})).unwrap();
|
||||
let extra = &s.project.render_queue[k].extra_outputs[0];
|
||||
assert_eq!((extra.format, extra.output.as_str()), (want, path), "addOutputModule {format} → {out}");
|
||||
}
|
||||
// No format: the extension picks it, as before. A format the extension agrees with keeps
|
||||
// the name as given (the CLI passes the extension as the format).
|
||||
s.execute("renderQueue.add", json!({"output": "/out/y.mov"})).unwrap();
|
||||
assert_eq!(s.project.render_queue.last().unwrap().output.format, OutputFormat::ProRes);
|
||||
s.execute("renderQueue.add", json!({"format": "png", "output": "/out/still.png"})).unwrap();
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.format, om.output.as_str()), (OutputFormat::PngSequence, "/out/still.png"));
|
||||
}
|
||||
|
||||
/// Output To only changes the format when the format can't write the new extension: HEVC and AV1
|
||||
/// write `.mp4` too, so they stay (#179). Other extensions still pick a file type.
|
||||
#[test]
|
||||
fn output_to_keeps_a_format_that_writes_the_extension() {
|
||||
let mut s = session();
|
||||
let cases = [
|
||||
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
|
||||
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
|
||||
("h264", "/out/x.mov", OutputFormat::ProRes, "/out/x.mov"),
|
||||
("prores", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
|
||||
("h264", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
|
||||
];
|
||||
for (format, out, want, path) in cases {
|
||||
let a = s.execute("renderQueue.add", json!({"format": format})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": out})).unwrap();
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
|
||||
if OutputFormat::from_name(format) == Some(want) {
|
||||
assert_eq!(r["outputModuleSummary"], a["outputModuleSummary"], "{format} → {out}: the module is unchanged");
|
||||
}
|
||||
}
|
||||
// The module's own default name (HEVC resolves to `Main.mp4`) changes nothing either.
|
||||
let a = s.execute("renderQueue.add", json!({"format": "hevc"})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": a["outputPath"]})).unwrap();
|
||||
assert_eq!((&r["output"]["format"], &r["outputModuleSummary"]), (&json!("Hevc"), &a["outputModuleSummary"]), "{r}");
|
||||
}
|
||||
|
||||
/// An explicit RGB + Alpha request with the AV1 WebM codec (which has no alpha) is refused, not
|
||||
/// silently rendered opaque (#166). Without `channels`, AV1 WebM renders RGB as before.
|
||||
#[test]
|
||||
fn av1_webm_refuses_requested_alpha() {
|
||||
let mut s = session();
|
||||
let n = s.project.render_queue.len();
|
||||
let e = s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
|
||||
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
|
||||
assert_eq!(s.project.render_queue.len(), n, "nothing queued");
|
||||
let a = s.execute("renderQueue.add", json!({"format": "webm", "channels": "rgba"})).unwrap();
|
||||
let e = s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
|
||||
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Vp9, Channels::Rgba), "unchanged");
|
||||
s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1"})).unwrap();
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Av1, Channels::Rgb));
|
||||
}
|
||||
|
||||
/// A `comp` that names no composition says so, rather than "no active composition" (#155).
|
||||
#[test]
|
||||
fn an_unknown_comp_is_named_in_the_error() {
|
||||
let mut s = session();
|
||||
for (cmd, p) in [
|
||||
("renderQueue.add", json!({"comp": "Nope", "output": "/out/x.mp4"})),
|
||||
("layer.newSolid", json!({"comp": "Nope"})),
|
||||
("comp.settings", json!({"comp": 99_999, "width": 10})),
|
||||
] {
|
||||
let e = s.execute(cmd, p.clone()).unwrap_err().to_string();
|
||||
let name = p["comp"].to_string();
|
||||
assert!(e.contains(&format!("no composition {name}")), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ fn project(s: &mut Session) -> effectcraft_project::ItemId {
|
|||
|
||||
#[test]
|
||||
fn disk_cache_settings_persistence_and_invalidation() {
|
||||
crate::tests_roto::hold_roto_cache_test_lock();
|
||||
let dir = folder("persist");
|
||||
let mut s = session(&dir);
|
||||
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
|
||||
|
|
@ -117,22 +116,7 @@ fn footage_changes_change_the_salt() {
|
|||
s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap();
|
||||
let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
assert_ne!(k1, k2);
|
||||
// Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't change it (#103).
|
||||
for sw in ["audio", "lock", "shy"] {
|
||||
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": sw, "value": sw != "audio"})).unwrap();
|
||||
}
|
||||
s.execute("comp.setSwitch", json!({"switch": "hideShy", "value": true})).unwrap();
|
||||
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
|
||||
// Unrelated comps do not change it.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap();
|
||||
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
|
||||
// A proxy, and its Use Proxy switch, change the pixels.
|
||||
let set_proxy = |s: &mut Session, enabled: bool| {
|
||||
let it = std::sync::Arc::make_mut(&mut s.project).items.get_mut(&cid).unwrap();
|
||||
it.proxy = Some(Box::new(effectcraft_project::Proxy { footage: effectcraft_project::Footage::default(), enabled }));
|
||||
effectcraft_render::disk_cache::comp_content_key(&s.project, cid)
|
||||
};
|
||||
let k3 = set_proxy(&mut s, true);
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,37 +104,3 @@ fn face_tracking_keys_outline_points_and_measurements() {
|
|||
let layer = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
assert!(layer.props.find_group(m3).unwrap().get("path").unwrap().keys.is_empty());
|
||||
}
|
||||
|
||||
/// With MediaPipe Face Landmarker installed (`EFFECTCRAFT_FACE_LANDMARKER` = path to
|
||||
/// `face_landmarker.task`, else skipped): Track Mask ▸ Face Tracking uses it, and the pupils and
|
||||
/// eye corners it keys sit on the synthetic face's.
|
||||
#[test]
|
||||
fn face_tracking_uses_the_chosen_model() {
|
||||
let Ok(model) = std::env::var("EFFECTCRAFT_FACE_LANDMARKER") else { return };
|
||||
let (mut s, clip, _) = setup(|f| render_synthetic(W, H, &face(f), 5));
|
||||
let dir = std::env::temp_dir().join(format!("ec-face-model-{}", std::process::id()));
|
||||
s.models_dir = Some(dir.clone());
|
||||
s.execute_checked("face.model.install", json!({"path": model})).unwrap();
|
||||
// `wait`: loaded before the track starts (as scripts need).
|
||||
let r = s.execute_checked("face.model.select", json!({"id": "mediapipe-face", "wait": true})).unwrap();
|
||||
assert_eq!(r["active"], "mediapipe-face");
|
||||
let c = face(0).center;
|
||||
let r = s.execute("mask.new", json!({"layer": clip.0, "vertices": ShapePath::rect(c, 120.0, 160.0).vertices, "closed": true})).unwrap();
|
||||
let mask = r["mask"].as_u64().unwrap();
|
||||
s.execute("time.set", json!({"time": 0})).unwrap();
|
||||
let r = s.execute_checked("track.mask", json!({"layer": clip.0, "mask": mask, "method": "faceDetailed", "direction": "forward", "wait": true})).unwrap();
|
||||
assert_eq!((r["frames"].as_u64(), r["faceModel"].as_str()), (Some(24), Some("mediapipe-face")), "{r}");
|
||||
let layer = s.active_comp().unwrap().layer(clip).unwrap().clone();
|
||||
let pts = layer.effects().unwrap().groups().find(|g| g.match_id == effectcraft_effects::face_track::POINTS_ID).expect("Face Track Points");
|
||||
let mut worst: f64 = 0.0;
|
||||
for f in [0, 8, 16, 24] {
|
||||
let truth = synth_landmarks(&face(f));
|
||||
for id in ["leftPupil", "rightPupil", "leftEyeOuter", "rightEyeOuter"] {
|
||||
let i = effectcraft_track::face::landmark(id).unwrap();
|
||||
let KV::Vec2(v) = pts.get(id).unwrap().value_at(frame_time(f)) else { panic!() };
|
||||
worst = worst.max((v[0] - truth[i][0]).hypot(v[1] - truth[i][1]) / face(f).height);
|
||||
}
|
||||
}
|
||||
assert!(worst < 0.05, "worst eye error {worst} face heights");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ fn import_export_helpers() {
|
|||
// Append Bit Depth to File Name.
|
||||
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png");
|
||||
p.export.append_bits_to_name = true;
|
||||
assert_eq!(PathBuf::from(p.output_name("/out/Comp 1.png", 16)), PathBuf::from("/out/Comp 1_16bpc.png"));
|
||||
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png");
|
||||
assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr");
|
||||
// Segments: sequences by file count, movies by size at the data rate.
|
||||
assert_eq!(p.segment_frames(100, true, 0.0), None);
|
||||
|
|
@ -299,7 +299,7 @@ fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
|
|||
assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB");
|
||||
item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB
|
||||
let segs = s.segments(&item, "/o/Main.mp4");
|
||||
assert_eq!(segs.iter().map(|(_, p)| PathBuf::from(p)).collect::<Vec<_>>(), [PathBuf::from("/o/Main_001.mp4"), PathBuf::from("/o/Main_002.mp4")]);
|
||||
assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::<Vec<_>>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -147,37 +147,3 @@ fn precompose_leave_and_move_attributes() {
|
|||
assert_eq!((nl.in_point.seconds(), nl.out_point.seconds()), (1.0, 3.0));
|
||||
assert_eq!(s.active_comp_id(), Some(inner));
|
||||
}
|
||||
|
||||
/// A precomp layer shows its comp's markers on its bar, mapped through its timing.
|
||||
#[test]
|
||||
fn nested_comp_markers_follow_the_precomp_layers_timing() {
|
||||
let mut s = crate::Session::default();
|
||||
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 32, "height": 32, "frameRate": 10, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
for (t, c) in [(1.0, "beat"), (3.0, "drop")] {
|
||||
s.execute("markers.set", json!({"new": true, "time": t, "comment": c})).unwrap();
|
||||
}
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 32, "height": 32, "frameRate": 10, "duration": 20})).unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": pre})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.timing", json!({"layers": [l], "start": 2.0})).unwrap();
|
||||
let times = |s: &mut crate::Session| -> Vec<(f64, String)> {
|
||||
let v = s.execute_checked("markers.nested", json!({"layer": l})).unwrap();
|
||||
v.as_array().unwrap().iter().map(|m| (m["time"].as_f64().unwrap(), m["comment"].as_str().unwrap().to_string())).collect()
|
||||
};
|
||||
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string()), (5.0, "drop".to_string())]);
|
||||
// Stretched to 200 %: twice as far from the start.
|
||||
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 200})).unwrap();
|
||||
assert_eq!(times(&mut s), vec![(4.0, "beat".to_string()), (8.0, "drop".to_string())]);
|
||||
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 100})).unwrap();
|
||||
// Trimmed: markers outside the In–Out range are not on the bar.
|
||||
s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
|
||||
assert_eq!(times(&mut s), vec![(3.0, "beat".to_string())]);
|
||||
s.execute("layer.timing", json!({"layers": [l], "out": 6.0})).unwrap();
|
||||
// Time remapping at half speed: nested 1 s is reached 2 s into the layer.
|
||||
s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "timeRemap", "time": 4.0, "value": 2.0})).unwrap();
|
||||
let t = times(&mut s);
|
||||
assert_eq!(t.first(), Some(&(4.0, "beat".to_string())), "{t:?}");
|
||||
// Not a precomp layer.
|
||||
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("markers.nested", json!({"layer": solid})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,6 @@ fn lift_and_extract_work_area() {
|
|||
|
||||
#[test]
|
||||
fn label_group_purge_and_edit_original() {
|
||||
crate::tests_roto::hold_roto_cache_test_lock();
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
//! Settings ▸ Roto Brush and Settings ▸ Face Tracking: the model registry, choosing, installing
|
||||
//! (verified, with its notice) and loading trained models.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_segment::Task;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::config::{ConfigStore, DirConfig};
|
||||
|
||||
fn session(tag: &str) -> (Session, std::path::PathBuf) {
|
||||
let d = std::env::temp_dir().join(format!("ec-models-{tag}-{}", std::process::id()));
|
||||
let store: Arc<dyn ConfigStore> = Arc::new(DirConfig::new(d.join("config")));
|
||||
(Session { config: Some(store), ..Default::default() }, d)
|
||||
}
|
||||
|
||||
/// Poll until `task`'s model is in use (or a few seconds pass).
|
||||
fn wait_loaded(s: &mut Session, task: Task) {
|
||||
for _ in 0..400 {
|
||||
s.poll_models();
|
||||
if s.models.status(task).active.is_some() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_are_listed_chosen_and_verified() {
|
||||
crate::tests_roto::hold_roto_cache_test_lock();
|
||||
let (mut s, d) = session("list");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let bogus = d.join("bogus.bin");
|
||||
std::fs::write(&bogus, b"not the weights").unwrap();
|
||||
for (prefix, model, task) in [("roto", "mobilesam", Task::Mask), ("face", "mediapipe-face", Task::Face)] {
|
||||
let list = s.execute_checked(&format!("{prefix}.models"), json!({})).unwrap();
|
||||
let ids: Vec<&str> = list["models"].as_array().unwrap().iter().map(|m| m["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["classical", model]);
|
||||
let m = &list["models"][1];
|
||||
assert_eq!((m["licence"].as_str(), m["installed"].as_bool()), (Some("Apache-2.0"), Some(false)));
|
||||
assert!(m["url"].as_str().unwrap().starts_with("https://") && !m["authors"].as_str().unwrap().is_empty());
|
||||
// Unknown ids, the other task's models and files that aren't the published weights are
|
||||
// refused.
|
||||
let other = if task == Task::Mask { "mediapipe-face" } else { "mobilesam" };
|
||||
for id in ["nope", other] {
|
||||
assert!(s.execute_checked(&format!("{prefix}.model.select"), json!({"id": id})).is_err(), "{prefix} {id}");
|
||||
}
|
||||
let path = bogus.to_string_lossy();
|
||||
let e = s.execute_checked(&format!("{prefix}.model.install"), json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
assert!(s.execute_checked(&format!("{prefix}.model.install"), json!({"path": path, "id": model})).is_err());
|
||||
// Choosing a model that isn't installed keeps the classic engine.
|
||||
s.execute_checked(&format!("{prefix}.model.select"), json!({"id": model})).unwrap();
|
||||
assert_eq!(s.chosen_model(task), model);
|
||||
assert!(s.models.status(task).active.is_none());
|
||||
assert!(crate::prefs::pages().iter().any(|p| p.id == prefix));
|
||||
}
|
||||
assert!(s.models.face().is_none());
|
||||
assert_eq!(crate::prefs::page_id("Face Tracking"), Some("face"));
|
||||
// With the real weights (EFFECTCRAFT_MOBILESAM=path/to/mobile_sam.pt): install, load, use.
|
||||
if let Ok(path) = std::env::var("EFFECTCRAFT_MOBILESAM") {
|
||||
s.execute_checked("roto.model.install", json!({"path": path})).unwrap();
|
||||
wait_loaded(&mut s, Task::Mask);
|
||||
assert_eq!(s.models.status(Task::Mask).active.as_deref(), Some("mobilesam"));
|
||||
// Roto Brush 3.0 (the default version) now segments with it.
|
||||
let params = crate::effects::Params { values: [("version".to_string(), effectcraft_keyframe::Value::Enum(2))].into_iter().collect() };
|
||||
assert_eq!(crate::effects::roto::model_for(¶ms).map(|m| m.info().id), Some("mobilesam"));
|
||||
s.execute_checked("roto.model.select", json!({"id": "classical"})).unwrap();
|
||||
assert!(crate::effects::roto::model_for(¶ms).is_none());
|
||||
}
|
||||
// EFFECTCRAFT_FACE_LANDMARKER=path/to/face_landmarker.task: installed with its notice,
|
||||
// loaded for face tracking, dropped when the classic tracker is chosen, removed.
|
||||
if let Ok(path) = std::env::var("EFFECTCRAFT_FACE_LANDMARKER") {
|
||||
let r = s.execute_checked("face.model.install", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["id"], "mediapipe-face");
|
||||
let dir = s.models_dir().unwrap();
|
||||
let notice = std::fs::read_to_string(dir.join("face_landmarker.task.NOTICE.txt")).unwrap();
|
||||
assert!(notice.contains("Google LLC") && notice.contains("Apache-2.0"), "{notice}");
|
||||
wait_loaded(&mut s, Task::Face);
|
||||
assert_eq!(s.models.face().map(|m| m.info().id), Some("mediapipe-face"));
|
||||
s.execute_checked("face.model.select", json!({"id": "classical"})).unwrap();
|
||||
assert!(s.models.face().is_none());
|
||||
assert_eq!(s.execute_checked("face.model.remove", json!({"id": "mediapipe-face"})).unwrap()["removed"], true);
|
||||
assert!(!dir.join("face_landmarker.task").exists() && !dir.join("face_landmarker.task.NOTICE.txt").exists());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
//! Project panel item edits: move into folders, rename, label, comment, with undo.
|
||||
|
||||
use effectcraft_project::{ItemId, LayerId};
|
||||
use effectcraft_project::ItemId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
|
@ -72,34 +72,6 @@ fn delete_and_duplicate_items() {
|
|||
assert!(s.project.item(dup).is_none());
|
||||
}
|
||||
|
||||
/// A new layer takes its Project item's label (#153); layers made before keep their own when the
|
||||
/// item's label changes. Layers with no item keep their type's default.
|
||||
#[test]
|
||||
fn new_layers_take_their_items_label() {
|
||||
use crate::color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemKind};
|
||||
let mut s = Session::default();
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("file.importPlaceholder", json!({"name": "Clip"})).unwrap();
|
||||
let clip = s.state.project_selection[0].0;
|
||||
let model = std::sync::Arc::make_mut(&mut s.project)
|
||||
.add_item("Model", Label::Aqua, None, ItemKind::Footage(Footage { kind: FootageKind::Model, path: "missing.glb".into(), ..Default::default() }))
|
||||
.0;
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let solid_item = s.active_comp().unwrap().layer(LayerId(solid)).unwrap().source.item().unwrap().0;
|
||||
let label = |s: &Session, layer: &serde_json::Value| s.active_comp().unwrap().layer(LayerId(layer["layer"].as_u64().unwrap())).unwrap().label;
|
||||
for (item, lab) in [(inner, "Blue"), (clip, "Green"), (model, "Orange"), (solid_item, "Fuchsia")] {
|
||||
let before = s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute("project.setLabel", json!({"items": [item], "label": lab})).unwrap();
|
||||
let after = s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
assert_eq!(label(&s, &after).name(), lab, "a new layer takes item {item}'s label");
|
||||
assert_ne!(label(&s, &before).name(), lab, "a layer made before keeps its own");
|
||||
}
|
||||
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
|
||||
assert_eq!(label(&s, &text), Label::Red, "no item: the type's default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_group_edits() {
|
||||
let mut s = Session::default();
|
||||
|
|
@ -124,46 +96,3 @@ fn property_group_edits() {
|
|||
let tr = s.active_comp().unwrap().layers[0].props.sub("transform").unwrap().uid;
|
||||
assert!(s.execute("prop.removeGroup", json!({"layer": l, "prop": tr})).is_err());
|
||||
}
|
||||
|
||||
/// Delete with a text animator, mask or shape group selected removes just those groups, in one
|
||||
/// undo step, and keeps their layers (#151). A fixed group selected still deletes the layer.
|
||||
#[test]
|
||||
fn delete_removes_selected_groups_not_their_layer() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let t = s.execute("layer.newText", json!({"text": "ABCD"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["tracking"]})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.addMask", json!({"layer": sh, "shape": "ellipse"})).unwrap();
|
||||
let groups = |s: &Session, l: u64, path: &str| -> Vec<u64> {
|
||||
s.active_comp().unwrap().layer(LayerId(l)).map_or(vec![], |l| l.props.group(path).unwrap().groups().map(|g| g.uid).collect())
|
||||
};
|
||||
let anim = groups(&s, t, "text/animators")[0];
|
||||
let (mask, shape) = (groups(&s, sh, "masks")[0], groups(&s, sh, "contents")[0]);
|
||||
let delete = |s: &mut Session| {
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
};
|
||||
|
||||
s.execute("prop.select", json!({"layer": t, "prop": anim, "selectKeys": false})).unwrap();
|
||||
let before = s.project.clone();
|
||||
delete(&mut s);
|
||||
assert!(groups(&s, t, "text/animators").is_empty(), "the animator is gone");
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "its layer stays");
|
||||
assert!(s.state.selected_props.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project, before);
|
||||
|
||||
s.execute("prop.select", json!({"layer": sh, "prop": mask, "selectKeys": false})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": sh, "prop": shape, "selectKeys": false, "add": true})).unwrap();
|
||||
delete(&mut s);
|
||||
assert!(groups(&s, sh, "masks").is_empty());
|
||||
assert!(!groups(&s, sh, "contents").contains(&shape));
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 2);
|
||||
|
||||
let tr = s.active_comp().unwrap().layer(LayerId(t)).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": t, "prop": tr, "selectKeys": false})).unwrap();
|
||||
delete(&mut s);
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(t)).is_none(), "a fixed group selected: the layer goes");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,6 @@ use serde_json::{Value, json};
|
|||
use crate::Session;
|
||||
use crate::tests_track::{H, W, frame_time, setup};
|
||||
|
||||
/// Keep cache/model mutation tests from clearing segmentations under another fixture.
|
||||
/// Libtest runs each test on its own thread; the guard lives until that thread exits.
|
||||
pub(crate) fn hold_roto_cache_test_lock() {
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static HELD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
|
||||
}
|
||||
HELD.with(|h| {
|
||||
if h.borrow().is_none() {
|
||||
*h.borrow_mut() = Some(LOCK.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
}
|
||||
});
|
||||
}
|
||||
const R: f64 = 40.0;
|
||||
|
||||
fn noise(x: f64, y: f64, seed: u32) -> f64 {
|
||||
|
|
@ -89,7 +74,6 @@ fn line(a: [f64; 2], b: [f64; 2]) -> Value {
|
|||
/// frame 0. `name`: the footage file name (segmentations are cached process-wide by content key,
|
||||
/// which includes the footage's identity: different clips need different names).
|
||||
fn painted(path: fn(u32) -> [f64; 2], name: &str) -> (Session, LayerId, u64) {
|
||||
hold_roto_cache_test_lock();
|
||||
let (mut s, clip, solid) = setup(move |f| draw(path(f)));
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.edit("remove solid", None, |p, _| {
|
||||
|
|
|
|||
|
|
@ -460,8 +460,8 @@ fn open_recent_increment_and_revert() {
|
|||
assert!(s.prefs.recent_projects[0].ends_with("A.ecproj"));
|
||||
s.execute("file.clearRecent", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("file.openRecent"));
|
||||
assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/Intro 9.ecproj", |_| false)), std::path::PathBuf::from("/x/Intro 10.ecproj"));
|
||||
assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj"))), std::path::PathBuf::from("/x/v1 3.ecproj"));
|
||||
assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj");
|
||||
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
|
|
@ -486,38 +486,3 @@ fn memory_tick_never_waits_for_the_system() {
|
|||
assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = Session { config: Some(store.clone()), ..Default::default() };
|
||||
assert_eq!(s.prefs.general.language, "en");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("ja"));
|
||||
let saved = store.read(PREFS_FILE).unwrap();
|
||||
for bad in [json!("fr"), 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);
|
||||
}
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
assert_eq!(reloaded.prefs.general.language, "ja");
|
||||
reloaded.execute("prefs.reset", json!({"page": "general"})).unwrap();
|
||||
assert_eq!(reloaded.prefs.general.language, "en");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"undoLevels":17}}"#).general.language, "en");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"unknown"}}"#).general.language, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_label_colors_fall_back_without_panicking() {
|
||||
use effectcraft_color::Label;
|
||||
let mut s = Session::default();
|
||||
let default = s.prefs.label_rgb(Label::Red);
|
||||
for color in ["#€abc", "#a€bc", "#abc€"] {
|
||||
s.execute("prefs.set", json!({"key": "labels.0.color", "value": color})).unwrap();
|
||||
assert_eq!(s.prefs.label_rgb(Label::Red), default);
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#aBcD09"})).unwrap();
|
||||
assert_eq!(s.prefs.label_rgb(Label::Red), [0xab, 0xcd, 9]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,20 +284,9 @@ fn add_output_module_encodes_twice() {
|
|||
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/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!(
|
||||
ex.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let (format, path) = entry.split_once(':').unwrap();
|
||||
(format.to_string(), std::path::PathBuf::from(path))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
[("H264".to_string(), std::path::absolute("/tmp/om/main.mp4").unwrap()), ("ProRes".to_string(), std::path::absolute("/tmp/om/second.mov").unwrap())]
|
||||
);
|
||||
assert_eq!(*ex.log.lock().unwrap(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]);
|
||||
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!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/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");
|
||||
}
|
||||
|
|
@ -318,9 +307,7 @@ fn pre_render_imports_and_replaces() {
|
|||
assert_eq!(ex.log.lock().unwrap().len(), 1);
|
||||
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())
|
||||
);
|
||||
assert!(matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/pre/inner.mov"));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
|
||||
}
|
||||
|
|
@ -336,15 +323,13 @@ fn post_render_actions_import_and_set_proxy() {
|
|||
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())
|
||||
));
|
||||
assert!(s.project.items.values().any(|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if f.path == "/tmp/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();
|
||||
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!(px.footage.path, "/tmp/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();
|
||||
|
|
@ -428,7 +413,7 @@ 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();
|
||||
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!(a["logPath"], "/tmp/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();
|
||||
|
|
|
|||
|
|
@ -227,35 +227,3 @@ fn opentype_features_per_range_render_and_query() {
|
|||
assert!(f["options"]["stylisticSets"].as_array().unwrap().contains(&json!(1)));
|
||||
assert_eq!(f["options"]["smallCaps"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_lists_bundled_and_installed_families() {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("text.fonts", json!({})).unwrap();
|
||||
let fams = r["families"].as_array().unwrap();
|
||||
assert_eq!(r["count"].as_u64().unwrap() as usize, fams.len());
|
||||
let inter = fams.iter().find(|f| f["family"] == "Inter").unwrap();
|
||||
assert_eq!(inter["origin"], "bundled");
|
||||
assert!(inter["styles"].as_array().unwrap().contains(&json!("Bold")));
|
||||
// Installed fonts are listed without any layer having asked for one.
|
||||
use effectcraft_text::fonts::FontSource as _;
|
||||
let installed = effectcraft_text::fonts::DirectorySource::system().faces();
|
||||
for face in &installed {
|
||||
assert!(fams.iter().any(|f| f["family"].as_str().unwrap().eq_ignore_ascii_case(&face.family)), "{} not listed", face.family);
|
||||
}
|
||||
// Filtered by name; rescanning keeps what was there.
|
||||
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
|
||||
assert_eq!(r["families"][0]["family"], "Noto Serif");
|
||||
// The bundled face is Regular; the OS can also provide Bold, Italic, or other faces.
|
||||
// Compare against the independent directory inventory rather than assuming an empty OS.
|
||||
let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]);
|
||||
expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone()));
|
||||
let styles: Vec<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), expected_styles);
|
||||
assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates");
|
||||
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
|
||||
// The style menus offer a family's own styles.
|
||||
assert_eq!(crate::font_styles("noto serif"), styles);
|
||||
assert_eq!(crate::font_styles("NOTO SERIF"), styles);
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::{ColorSpace, space};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_geom::vec2;
|
||||
use effectcraft_project::{Layer, LayerId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
|
|
@ -131,30 +131,21 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
|
|||
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
|
||||
/// (2D layers; `None` for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let mut out = vec![];
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
let mut out = box_features(ctx, layer, &m);
|
||||
out.extend(anchor_feature(ctx, layer, &m));
|
||||
out
|
||||
}
|
||||
|
||||
/// The corners, edge midpoints and centre of a layer's content box (`m`: layer → comp).
|
||||
fn box_features(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Vec<[f64; 2]> {
|
||||
let Some(b) = effectcraft_render::content_bounds(ctx, layer) else { return vec![] };
|
||||
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
[[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]]
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
|
||||
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
|
||||
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
|
||||
let q = m.apply(vec2(p[0], p[1]));
|
||||
[q.x, q.y]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A layer's anchor point (`m`: layer → comp).
|
||||
fn anchor_feature(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<[f64; 2]> {
|
||||
let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]);
|
||||
let q = m.apply(vec2(a[0], a[1]));
|
||||
Some([q.x, q.y])
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
}
|
||||
if let Some(tr) = layer.transform() {
|
||||
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
|
||||
let q = m.apply(vec2(a[0], a[1]));
|
||||
out.push([q.x, q.y]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mask and shape-path vertices of a layer in comp space.
|
||||
|
|
@ -172,7 +163,7 @@ pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
|||
}
|
||||
}
|
||||
for (uid, sp) in shape_paths(ctx, layer) {
|
||||
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(Mat3::IDENTITY);
|
||||
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
|
||||
out.extend(sp.vertices.iter().map(|v| {
|
||||
let q = pm.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
|
|
@ -223,8 +214,19 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
|
|||
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
|
||||
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
|
||||
];
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id)) {
|
||||
out.extend(layer_targets(ctx, l, true));
|
||||
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
|
||||
let f = layer_features(ctx, l);
|
||||
// Edges of unrotated layers are lines too.
|
||||
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
|
||||
for x in [f[0][0], f[1][0]] {
|
||||
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
for y in [f[0][1], f[3][1]] {
|
||||
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
}
|
||||
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
}
|
||||
if opts.guides {
|
||||
for g in &comp.guides {
|
||||
|
|
@ -251,33 +253,6 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
|
|||
out
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets: its box points (edges as lines too while unrotated), its anchor
|
||||
/// point unless `anchor` is false (Pan Behind drags it, the box stays put) and its mask and shape
|
||||
/// path vertices. None for cameras, lights and 3D layers.
|
||||
pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool) -> Vec<SnapTarget> {
|
||||
if l.is_camera() || l.is_light() || l.is_3d() {
|
||||
return vec![];
|
||||
}
|
||||
let (m, _) = ctx.layer_to_comp(l);
|
||||
let f = box_features(ctx, l, &m);
|
||||
let mut out = vec![];
|
||||
if let [nw, ne, _, sw, ..] = f.as_slice()
|
||||
&& (nw[1] - ne[1]).abs() < 1e-6
|
||||
&& (nw[0] - sw[0]).abs() < 1e-6
|
||||
{
|
||||
for x in [nw[0], ne[0]] {
|
||||
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, nw[1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
for y in [nw[1], sw[1]] {
|
||||
out.push(SnapTarget { kind: SnapKind::HLine, pos: [nw[0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
|
||||
}
|
||||
}
|
||||
let a = if anchor { anchor_feature(ctx, l, &m) } else { None };
|
||||
out.extend(f.into_iter().chain(a).map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels and exposure
|
||||
|
||||
/// Show Channel and Color Management Settings.
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ impl VideoEncoder for ProRes {
|
|||
let mut fr = filmcraft_prores::Frame::new(self.w, self.h, chroma, 10, self.alpha);
|
||||
rgba_to_yuv_10(rgba, self.w as usize, self.h as usize, self.is_444(), &mut fr.y, &mut fr.cb, &mut fr.cr);
|
||||
if let Some(a) = fr.alpha.as_mut() {
|
||||
for (d, s) in a.iter_mut().zip(rgba.as_chunks::<4>().0.iter()) {
|
||||
for (d, s) in a.iter_mut().zip(rgba.chunks_exact(4)) {
|
||||
*d = ((s[3] as u32 * 1023 + 127) / 255) as u16;
|
||||
}
|
||||
}
|
||||
|
|
@ -217,7 +217,7 @@ fn deinterleave(buf: &[f32], channels: usize) -> Vec<Vec<f32>> {
|
|||
/// (mono = the average of left and right).
|
||||
pub(crate) fn mix(cx: &Cx, start: effectcraft_time::Tick, n: usize, sr: u32) -> Vec<f32> {
|
||||
let st = effectcraft_render::audio::mix_comp(&cx.project, cx.footage, cx.expr, cx.comp, start, n, sr);
|
||||
if cx.output.audio_channels == 1 { st.as_chunks::<2>().0.iter().map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
|
||||
if cx.output.audio_channels == 1 { st.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
|
||||
}
|
||||
|
||||
/// PCM bytes of samples in `fmt` (little or big endian).
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ fn write_still<W: std::io::Write + std::io::Seek>(
|
|||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
let rgb: Vec<u8> = px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb: Vec<u8> = px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100))
|
||||
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
|
||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||
|
|
@ -331,8 +331,8 @@ fn write_still<W: std::io::Write + std::io::Seek>(
|
|||
let px = cx.pixels(img, comp, channels, w, h);
|
||||
let (data, ct) = match channels {
|
||||
Channels::Rgba => (px, ExtendedColorType::Rgba8),
|
||||
Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8),
|
||||
Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
|
||||
Channels::Alpha => (px.chunks_exact(4).map(|p| p[0]).collect(), ExtendedColorType::L8),
|
||||
Channels::Rgb => (px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
|
||||
};
|
||||
if fmt == OutputFormat::PngSequence {
|
||||
image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ fn ffmpeg_oracle_decodes_ogg_opus() {
|
|||
.expect("run ffmpeg");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let dec: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let dec: Vec<f32> = out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
assert_eq!(dec.len(), len * c, "ffmpeg must honour pre-skip and the end granule");
|
||||
for ch in 0..c {
|
||||
let d: Vec<f32> = (0..len).map(|i| dec[i * c + ch]).collect();
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ fn render_logs() {
|
|||
let r = r.unwrap();
|
||||
let (path, text) = log_of(&files).expect("log");
|
||||
assert_eq!(r.log.as_deref(), Some(path.as_str()));
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/b_RenderLog.txt"));
|
||||
assert_eq!(path, "/l/b_RenderLog.txt");
|
||||
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
|
||||
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
|
||||
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
|
||||
|
|
@ -239,7 +239,7 @@ fn render_logs() {
|
|||
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
|
||||
assert!(r.is_err());
|
||||
let (path, text) = log_of(&files).expect("error log");
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/d_RenderLog.txt"));
|
||||
assert_eq!(path, "/l/d_RenderLog.txt");
|
||||
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
|
||||
// A user stop is not an error: no log.
|
||||
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
|
||||
|
|
@ -255,7 +255,7 @@ struct Quota {
|
|||
|
||||
impl StorageQuota for Quota {
|
||||
fn has_room(&self, path: &str, bytes: u64) -> bool {
|
||||
if !std::path::Path::new(path).starts_with("/primary") {
|
||||
if !path.starts_with("/primary/") {
|
||||
return true;
|
||||
}
|
||||
let mut u = self.used.lock().unwrap();
|
||||
|
|
@ -278,14 +278,7 @@ fn storage_overflow_moves_files_to_the_overflow_folder() {
|
|||
let r = r.unwrap();
|
||||
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
|
||||
let f = files.lock().unwrap();
|
||||
assert_eq!(
|
||||
f.iter()
|
||||
.filter(|(p, _)| std::path::Path::new(p).parent() == Some(std::path::Path::new("/overflow"))
|
||||
&& std::path::Path::new(p).file_name().is_some_and(|n| n.to_string_lossy().starts_with("seq_"))
|
||||
&& p.ends_with(".png"))
|
||||
.count(),
|
||||
10
|
||||
);
|
||||
assert_eq!(f.iter().filter(|(p, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10);
|
||||
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
|
||||
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
|
||||
drop(f);
|
||||
|
|
@ -295,7 +288,7 @@ fn storage_overflow_moves_files_to_the_overflow_folder() {
|
|||
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
|
||||
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
|
||||
assert!(r.unwrap().overflow.is_empty());
|
||||
assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(p).starts_with("/primary")));
|
||||
assert!(files.lock().unwrap().iter().all(|(p, _)| p.starts_with("/primary/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ fn wav_and_aiff_audio_only() {
|
|||
} else {
|
||||
// AIFF: big-endian samples after the SSND chunk's offset/block size.
|
||||
let at = bytes.windows(4).position(|w| w == b"SSND").expect("SSND") + 16;
|
||||
let left = bytes[at..].as_chunks::<4>().0.iter().skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
|
||||
let left = bytes[at..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
|
||||
rms(left)
|
||||
};
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
|
|
|
|||
|
|
@ -259,22 +259,11 @@ fn inter_frames_are_much_smaller_than_intra() {
|
|||
eprintln!("{report:#?}");
|
||||
}
|
||||
|
||||
/// Encode `n` frames of `moving(.., kind)`: (frames, the encoder's reconstruction of each).
|
||||
fn encode_moving(cfg: EncoderConfig, n: usize, kind: u32) -> (Vec<Vec<u8>>, Vec<[Vec<u8>; 3]>) {
|
||||
let (w, h) = (cfg.width as usize, cfg.height as usize);
|
||||
let mut e = Vp9Encoder::new(cfg);
|
||||
(0..n)
|
||||
.map(|t| {
|
||||
let (y, u, v) = moving(w, h, t, kind);
|
||||
let f = e.encode_frame(&y, &u, &v, false);
|
||||
(f.data, e.reconstruction().expect("recon"))
|
||||
})
|
||||
.unzip()
|
||||
}
|
||||
|
||||
/// Decode `frames` with ffmpeg (an IVF file) and check every frame against `recon`, with nothing
|
||||
/// on ffmpeg's error log. Skipped without ffmpeg.
|
||||
fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec<u8>], recon: &[[Vec<u8>; 3]], name: &str) {
|
||||
#[test]
|
||||
fn ffmpeg_decodes_inter_frames_like_the_encoder() {
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let mut e = Vp9Encoder::new(EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() });
|
||||
let n = 7;
|
||||
let mut ivf = Vec::new();
|
||||
ivf.extend_from_slice(b"DKIF");
|
||||
ivf.extend_from_slice(&0u16.to_le_bytes());
|
||||
|
|
@ -284,14 +273,18 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec<u8>], recon: &[[Vec<u8>; 3]]
|
|||
ivf.extend_from_slice(&(h as u16).to_le_bytes());
|
||||
ivf.extend_from_slice(&30u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&1u32.to_le_bytes());
|
||||
ivf.extend_from_slice(&(frames.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&(n as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&0u32.to_le_bytes());
|
||||
for (t, f) in frames.iter().enumerate() {
|
||||
ivf.extend_from_slice(&(f.len() as u32).to_le_bytes());
|
||||
let mut recon = vec![];
|
||||
for t in 0..n {
|
||||
let (y, u, v) = moving(w, h, t, 0);
|
||||
let f = e.encode_frame(&y, &u, &v, false);
|
||||
ivf.extend_from_slice(&(f.data.len() as u32).to_le_bytes());
|
||||
ivf.extend_from_slice(&(t as u64).to_le_bytes());
|
||||
ivf.extend_from_slice(f);
|
||||
ivf.extend_from_slice(&f.data);
|
||||
recon.push(e.reconstruction().expect("recon"));
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-{name}-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-vp9-inter-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.ivf");
|
||||
std::fs::write(&path, &ivf).unwrap();
|
||||
|
|
@ -304,9 +297,9 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec<u8>], recon: &[[Vec<u8>; 3]]
|
|||
eprintln!("ffmpeg not found: skipping oracle");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let fsz = w * h + 2 * w.div_ceil(2) * h.div_ceil(2);
|
||||
assert_eq!(out.stdout.len(), fsz * frames.len());
|
||||
assert_eq!(out.stdout.len(), fsz * n);
|
||||
for (t, r) in recon.iter().enumerate() {
|
||||
let frame = &out.stdout[t * fsz..(t + 1) * fsz];
|
||||
assert_eq!(&frame[..w * h], &r[0][..], "ffmpeg luma of frame {t}");
|
||||
|
|
@ -314,44 +307,6 @@ fn ffmpeg_matches(w: usize, h: usize, frames: &[Vec<u8>], recon: &[[Vec<u8>; 3]]
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_decodes_inter_frames_like_the_encoder() {
|
||||
let (w, h) = (96usize, 64usize);
|
||||
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: 75, keyframe_interval: 5, ..Default::default() };
|
||||
let (frames, recon) = encode_moving(cfg, 7, 0);
|
||||
ffmpeg_matches(w, h, &frames, &recon, "inter");
|
||||
}
|
||||
|
||||
/// A frame whose data would end in a byte that reads as a superframe marker (0b110xxxxx) gets a
|
||||
/// zero byte after it; otherwise decoders take its tail for a superframe index and reject it
|
||||
/// (#161: 3 of 60 frames of a WebM failed to decode). Such frames still decode exactly.
|
||||
#[test]
|
||||
fn frames_never_end_in_a_superframe_marker() {
|
||||
let marker = |b: u8| b & 0xe0 == 0xc0;
|
||||
let mut padded = None;
|
||||
for (w, h) in [(96usize, 64usize), (130, 66), (160, 96)] {
|
||||
for q in [40u8, 60, 75, 90] {
|
||||
let cfg = EncoderConfig { width: w as u32, height: h as u32, quality: q, keyframe_interval: 12, ..Default::default() };
|
||||
let (frames, recon) = encode_moving(cfg, 24, 1);
|
||||
for (t, f) in frames.iter().enumerate() {
|
||||
assert!(!f.last().copied().is_some_and(marker), "{w}x{h} q{q} frame {t} ends in {:#x}", f[f.len() - 1]);
|
||||
}
|
||||
let needed = frames.iter().any(|f| f.len() >= 2 && f[f.len() - 1] == 0 && marker(f[f.len() - 2]));
|
||||
if needed && padded.is_none() {
|
||||
padded = Some((w, h, q, frames, recon));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") };
|
||||
// The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg.
|
||||
let mut d = Decoder::new();
|
||||
for (t, (f, r)) in frames.iter().zip(&recon).enumerate() {
|
||||
let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}"));
|
||||
assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}");
|
||||
}
|
||||
ffmpeg_matches(w, h, &frames, &recon, "marker");
|
||||
}
|
||||
|
||||
/// Encoder speed on 720p (run with `--ignored --nocapture`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
|
|
|||
|
|
@ -21,14 +21,9 @@ effectcraft-keyframe = { workspace = true }
|
|||
wgpu = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
pollster = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
effectcraft-text = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "=0.2.129"
|
||||
js-sys = "0.3"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! (`Depth32Float`, Greater), the scene's materials, textures, lights and shadow maps in
|
||||
//! storage buffers, and `advanced3d.wgsl`'s physically based fragment shader. Opaque triangles
|
||||
//! are drawn with depth writes, then the sorted transparent ones blended over (premultiplied).
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Uint` holding f32 bits) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! compute kernels, which run the rest of `effectcraft_render::three_d::adv::render_prepared`:
|
||||
//! the 2×2 supersampling resolve, the motion-blur sub-sample average (nearest depth), the
|
||||
//! depth-based iris depth of field (the Classic 3D bokeh kernel's row spans and prefix-sum
|
||||
|
|
@ -21,194 +21,21 @@ use crate::context::{Enc, GpuContext, GpuImage};
|
|||
|
||||
/// Pipelines (built on first use).
|
||||
pub(crate) struct Pipes {
|
||||
/// (opaque, transparent); `None` when the adapter can't rasterise ([`raster_unsupported`]):
|
||||
/// scenes then render on the CPU.
|
||||
raster: Option<(wgpu::BindGroupLayout, wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
opaque: wgpu::RenderPipeline,
|
||||
transparent: wgpu::RenderPipeline,
|
||||
post: Post,
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device, g.adv3d_raster))
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device))
|
||||
}
|
||||
|
||||
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// Camera depth as the bits of an f32: integer targets render on every backend, while `R32Float`
|
||||
/// needs `EXT_color_buffer_float` on GLES (absent on Mesa's llvmpipe, for one).
|
||||
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
|
||||
/// [`DEPTH_OUT`]'s clear value: the bits of −1.0 ("nothing drawn").
|
||||
const NO_DEPTH: f64 = 0xBF80_0000u32 as f64;
|
||||
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Float;
|
||||
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
const VERTEX_SIZE: u64 = 52;
|
||||
|
||||
/// Why `adapter` can't run the rasteriser, or `None` when it can. Building the pipelines anyway
|
||||
/// would be a validation error, which release builds only log (and then draw nothing), so the
|
||||
/// caller keeps the scenes on the CPU instead.
|
||||
pub(crate) fn raster_unsupported(adapter: &wgpu::Adapter) -> Option<String> {
|
||||
raster_capability_reason(adapter.get_downlevel_capabilities().flags, |format| adapter.get_texture_format_features(format))
|
||||
}
|
||||
|
||||
fn raster_capability_reason(flags: wgpu::DownlevelFlags, features: impl Fn(wgpu::TextureFormat) -> wgpu::TextureFormatFeatures) -> Option<String> {
|
||||
use wgpu::TextureFormatFeatureFlags as F;
|
||||
use wgpu::TextureUsages as U;
|
||||
if !flags.contains(wgpu::DownlevelFlags::FRAGMENT_STORAGE) {
|
||||
return Some("no fragment storage buffers".into());
|
||||
}
|
||||
let targets = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING | U::COPY_SRC;
|
||||
let needs = [(COLOR, targets, F::BLENDABLE), (DEPTH_OUT, targets, F::empty()), (DEPTH, U::RENDER_ATTACHMENT, F::empty())];
|
||||
for (format, usages, required_flags) in needs {
|
||||
let f = features(format);
|
||||
if !f.allowed_usages.contains(usages) || !f.flags.contains(required_flags) {
|
||||
return Some(format!("{format:?} lacks raster usages {usages:?} or flags {required_flags:?}"));
|
||||
}
|
||||
}
|
||||
// The transparent pipeline blends colour and masks the depth target off.
|
||||
if !flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND) {
|
||||
return Some("no independent blending".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bound one allocation by its actual byte stride, without imposing a global image limit.
|
||||
fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option<u64> {
|
||||
let size = count.checked_mul(stride)?;
|
||||
(size > 0 && size <= limit && usize::try_from(size).is_ok()).then_some(size)
|
||||
}
|
||||
|
||||
/// Sizes of exactly the arrays serialized by pack, including its empty storage padding.
|
||||
/// Checking these before serialization bounds host packing as well as the GPU uploads.
|
||||
fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> {
|
||||
let mut sizes = [0; 7];
|
||||
sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?;
|
||||
for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() {
|
||||
let size = count.checked_mul(stride)?.max(16);
|
||||
sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?;
|
||||
}
|
||||
Some(sizes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_tests {
|
||||
use super::*;
|
||||
|
||||
fn supported(format: wgpu::TextureFormat) -> wgpu::TextureFormatFeatures {
|
||||
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
wgpu::TextureFormatFeatures {
|
||||
allowed_usages: if format == DEPTH { rt } else { rt | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC },
|
||||
flags: if format == COLOR { wgpu::TextureFormatFeatureFlags::BLENDABLE } else { wgpu::TextureFormatFeatureFlags::empty() },
|
||||
}
|
||||
}
|
||||
|
||||
fn downlevel() -> wgpu::DownlevelFlags {
|
||||
wgpu::DownlevelFlags::FRAGMENT_STORAGE | wgpu::DownlevelFlags::INDEPENDENT_BLEND
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_raster_capabilities_are_accepted_without_optional_features() {
|
||||
assert!(raster_capability_reason(downlevel(), supported).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_storage_and_independent_blending_are_required() {
|
||||
for flag in [wgpu::DownlevelFlags::FRAGMENT_STORAGE, wgpu::DownlevelFlags::INDEPENDENT_BLEND] {
|
||||
assert!(raster_capability_reason(downlevel() - flag, supported).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_and_integer_depth_require_their_actual_texture_usages() {
|
||||
for target in [COLOR, DEPTH_OUT] {
|
||||
for usage in [wgpu::TextureUsages::RENDER_ATTACHMENT, wgpu::TextureUsages::TEXTURE_BINDING, wgpu::TextureUsages::COPY_SRC] {
|
||||
assert!(
|
||||
raster_capability_reason(downlevel(), |format| {
|
||||
let mut features = supported(format);
|
||||
if format == target {
|
||||
features.allowed_usages.remove(usage);
|
||||
}
|
||||
features
|
||||
})
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blending_and_depth_attachment_are_required() {
|
||||
for target in [COLOR, DEPTH] {
|
||||
assert!(
|
||||
raster_capability_reason(downlevel(), |format| {
|
||||
let mut features = supported(format);
|
||||
if format == target {
|
||||
if target == COLOR {
|
||||
features.flags.remove(wgpu::TextureFormatFeatureFlags::BLENDABLE);
|
||||
} else {
|
||||
features.allowed_usages.remove(wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
}
|
||||
}
|
||||
features
|
||||
})
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_guards_use_each_buffer_stride_and_reject_overflow() {
|
||||
assert_eq!(buffer_bytes(2, VERTEX_SIZE, 104), Some(104));
|
||||
assert_eq!(buffer_bytes(2, VERTEX_SIZE, 103), None);
|
||||
assert_eq!(buffer_bytes(26, 4, 104), Some(104));
|
||||
assert_eq!(buffer_bytes(27, 4, 104), None);
|
||||
assert_eq!(buffer_bytes(0, 4, 104), None);
|
||||
assert_eq!(buffer_bytes(u64::MAX, 4, u64::MAX), None);
|
||||
assert_eq!(buffer_bytes(1, 16, 15), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_arrays_preflight_exact_strides_and_empty_padding() {
|
||||
assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16]));
|
||||
assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16]));
|
||||
assert!(packed_bytes([0; 6], 159, 16, 160).is_none());
|
||||
assert!(packed_bytes([0; 6], 160, 15, 160).is_none());
|
||||
assert!(packed_bytes([2; 6], 160, 192, 191).is_none());
|
||||
for index in 0..6 {
|
||||
let mut counts = [0; 6];
|
||||
counts[index] = u64::MAX;
|
||||
assert!(packed_bytes(counts, u64::MAX, u64::MAX, u64::MAX).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicted_sizes_match_the_real_scene_serializer() {
|
||||
use effectcraft_render::three_d::adv::{Light, Material, ShadowMap, TexInfo};
|
||||
let scene = Scene {
|
||||
materials: vec![Material::default()],
|
||||
textures: vec![TexInfo { offset: 0, width: 1, height: 1, wrap_u: 0, wrap_v: 0 }],
|
||||
texels: vec![[0.0; 4]],
|
||||
lights: vec![Light {
|
||||
kind: 0,
|
||||
pos: [0.0; 3],
|
||||
dir: [0.0; 3],
|
||||
color: [0.0; 3],
|
||||
cos_inner: 0.0,
|
||||
cos_outer: 0.0,
|
||||
falloff: 0,
|
||||
radius: 0.0,
|
||||
falloff_distance: 0.0,
|
||||
shadow: -1,
|
||||
shadow_darkness: 0.0,
|
||||
shadow_diffusion: 0.0,
|
||||
}],
|
||||
shadows: vec![ShadowMap { view: [[0.0; 4]; 3], focal: 0.0, ortho: false, size: 1, offset: 0, bias: 0.0, radius: 0.0 }],
|
||||
shadow_texels: vec![0.0],
|
||||
..Scene::default()
|
||||
};
|
||||
for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] {
|
||||
let actual = pack(&scene).map(|bytes| bytes.len() as u64);
|
||||
assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
|
|
@ -219,79 +46,71 @@ fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
|||
}
|
||||
|
||||
impl Pipes {
|
||||
fn new(device: &wgpu::Device, raster: bool) -> Pipes {
|
||||
let raster = raster.then(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/advanced3d.wgsl").into()),
|
||||
});
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
}];
|
||||
entries.extend((1..=6).map(storage));
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("advanced 3d"), entries: &entries });
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("advanced 3d"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let attrs = [
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 12, shader_location: 1 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 24, shader_location: 2 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 3 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Uint32, offset: 48, shader_location: 4 },
|
||||
];
|
||||
let make = |transparent: bool| {
|
||||
let blend = transparent.then_some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(if transparent { "advanced 3d transparent" } else { "advanced 3d opaque" }),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: VERTEX_SIZE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &attrs })],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH,
|
||||
depth_write_enabled: Some(!transparent),
|
||||
depth_compare: Some(wgpu::CompareFunction::Greater),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState { format: COLOR, blend, write_mask: wgpu::ColorWrites::ALL }),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DEPTH_OUT,
|
||||
blend: None,
|
||||
write_mask: if transparent { wgpu::ColorWrites::empty() } else { wgpu::ColorWrites::ALL },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let opaque = make(false);
|
||||
let transparent = make(true);
|
||||
(bgl, opaque, transparent)
|
||||
fn new(device: &wgpu::Device) -> Pipes {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/advanced3d.wgsl").into()),
|
||||
});
|
||||
Pipes { raster, post: Post::new(device) }
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
}];
|
||||
entries.extend((1..=6).map(storage));
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("advanced 3d"), entries: &entries });
|
||||
let layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("advanced 3d"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
|
||||
let attrs = [
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 12, shader_location: 1 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 24, shader_location: 2 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 3 },
|
||||
wgpu::VertexAttribute { format: wgpu::VertexFormat::Uint32, offset: 48, shader_location: 4 },
|
||||
];
|
||||
let make = |transparent: bool| {
|
||||
let blend = transparent.then_some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(if transparent { "advanced 3d transparent" } else { "advanced 3d opaque" }),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: VERTEX_SIZE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &attrs })],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH,
|
||||
depth_write_enabled: Some(!transparent),
|
||||
depth_compare: Some(wgpu::CompareFunction::Greater),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState { format: COLOR, blend, write_mask: wgpu::ColorWrites::ALL }),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DEPTH_OUT,
|
||||
blend: None,
|
||||
write_mask: if transparent { wgpu::ColorWrites::empty() } else { wgpu::ColorWrites::ALL },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl, post: Post::new(device) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,33 +222,20 @@ fn half_to_f32(h: u16) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Uint`
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Float`
|
||||
/// with −1 where nothing was drawn) at the scene's raster size. `None` when the device can't
|
||||
/// (render targets, size or buffer limits).
|
||||
/// (size or buffer limits).
|
||||
fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let g = e.g;
|
||||
if !g.adv3d_raster || !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
if !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limits = g.device.limits();
|
||||
buffer_bytes(u64::try_from(s.vertices.len()).ok()?, VERTEX_SIZE, limits.max_buffer_size)?;
|
||||
buffer_bytes(u64::try_from(s.indices.len()).ok()?, 4, limits.max_buffer_size)?;
|
||||
let n = u32::try_from(s.indices.len()).ok()?;
|
||||
if s.opaque_count > n {
|
||||
return None;
|
||||
}
|
||||
let counts = [s.materials.len(), s.textures.len(), s.texels.len(), s.lights.len(), s.shadows.len(), s.shadow_texels.len()];
|
||||
let mut checked_counts = [0; 6];
|
||||
for (out, count) in checked_counts.iter_mut().zip(counts) {
|
||||
*out = u64::try_from(count).ok()?;
|
||||
}
|
||||
let expected_sizes = packed_bytes(checked_counts, limits.max_uniform_buffer_binding_size, limits.max_storage_buffer_binding_size, limits.max_buffer_size)?;
|
||||
let bufs = pack(s);
|
||||
if bufs.iter().zip(expected_sizes).any(|(bytes, expected)| bytes.len() as u64 != expected) {
|
||||
if bufs.iter().any(|b| b.len() as u64 > limits.max_storage_buffer_binding_size) {
|
||||
return None;
|
||||
}
|
||||
let p = pipes(g);
|
||||
let Some((bgl, opaque, transparent)) = &p.raster else { return None };
|
||||
let dev = &g.device;
|
||||
let vb: Vec<u8> = s
|
||||
.vertices
|
||||
|
|
@ -455,7 +261,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
for (i, b) in sbs.iter().enumerate() {
|
||||
entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() });
|
||||
}
|
||||
let bg = dev.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("adv3d"), layout: bgl, entries: &entries });
|
||||
let bg = dev.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("adv3d"), layout: &p.bgl, entries: &entries });
|
||||
let tex = |format: wgpu::TextureFormat, usage: wgpu::TextureUsages| {
|
||||
dev.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("adv3d target"),
|
||||
|
|
@ -488,7 +294,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
view: &zv,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: NO_DEPTH, g: 0.0, b: 0.0, a: 0.0 }), store: wgpu::StoreOp::Store },
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: -1.0, g: 0.0, b: 0.0, a: 0.0 }), store: wgpu::StoreOp::Store },
|
||||
}),
|
||||
],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
|
|
@ -503,12 +309,13 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.set_vertex_buffer(0, vbuf.slice(..));
|
||||
pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
|
||||
let n = s.indices.len() as u32;
|
||||
if s.opaque_count > 0 {
|
||||
pass.set_pipeline(opaque);
|
||||
pass.set_pipeline(&p.opaque);
|
||||
pass.draw_indexed(0..s.opaque_count, 0, 0..1);
|
||||
}
|
||||
if n > s.opaque_count {
|
||||
pass.set_pipeline(transparent);
|
||||
pass.set_pipeline(&p.transparent);
|
||||
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
|
@ -555,11 +362,9 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
|||
if cb.len() != (s.width * s.height * 8) as usize || zb.len() != (s.width * s.height * 4) as usize {
|
||||
return None;
|
||||
}
|
||||
let color = cb.as_chunks::<8>().0.iter().map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
|
||||
let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
|
||||
let depth = zb
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.chunks_exact(4)
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z < 0.0 { f32::INFINITY } else { z }
|
||||
|
|
@ -619,10 +424,14 @@ impl Post {
|
|||
label: Some("effectcraft advanced 3d post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/adv3d.wgsl").into()),
|
||||
});
|
||||
let tex = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
let tex = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture { sample_type, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let buf = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
|
|
@ -638,8 +447,8 @@ impl Post {
|
|||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex(1, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
tex(2, wgpu::TextureSampleType::Uint),
|
||||
tex(1),
|
||||
tex(2),
|
||||
buf(3, false),
|
||||
buf(4, false),
|
||||
buf(5, false),
|
||||
|
|
@ -772,11 +581,6 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
|||
if !e.g.can_wait() {
|
||||
return None;
|
||||
}
|
||||
buffer_bytes(size, 1, e.g.device.limits().max_buffer_size)?;
|
||||
let row = usize::try_from(size).ok()?;
|
||||
if size > buf.size() || !size.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT) {
|
||||
return None;
|
||||
}
|
||||
let rb = e.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d readback"),
|
||||
size,
|
||||
|
|
@ -785,7 +589,14 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
|||
});
|
||||
e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
rb.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
e.g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
|
||||
rx.recv().ok()?.ok()?;
|
||||
let out = rb.get_mapped_range(..).ok()?.to_vec();
|
||||
rb.unmap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
|
@ -811,12 +622,12 @@ pub(crate) fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolve
|
|||
return None;
|
||||
}
|
||||
let px = w as u64 * h as u64;
|
||||
let limits = g.device.limits();
|
||||
let max = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
|
||||
let acc_bytes = buffer_bytes(px, 16, max)?;
|
||||
let depth_bytes = buffer_bytes(px, 4, max)?;
|
||||
let acc = storage_buf(g, acc_bytes, "adv3d acc");
|
||||
let depth = storage_buf(g, depth_bytes, "adv3d depth");
|
||||
let max = g.device.limits().max_storage_buffer_binding_size;
|
||||
if px * 16 > max {
|
||||
return None;
|
||||
}
|
||||
let acc = storage_buf(g, px * 16, "adv3d acc");
|
||||
let depth = storage_buf(g, px * 4, "adv3d depth");
|
||||
let n = prep.samples.max(1);
|
||||
let weight = if n <= 1 { 1.0 } else { 1.0 / n as f32 };
|
||||
let mut first = true;
|
||||
|
|
@ -825,7 +636,7 @@ pub(crate) fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolve
|
|||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if Some(s.width) != w.checked_mul(s.ssaa.max(1)) || Some(s.height) != h.checked_mul(s.ssaa.max(1)) {
|
||||
if s.width != w * s.ssaa.max(1) || s.height != h * s.ssaa.max(1) {
|
||||
return None;
|
||||
}
|
||||
let (color, zout) = raster_into(e, &s)?;
|
||||
|
|
@ -850,10 +661,9 @@ pub(crate) fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolve
|
|||
fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): (u32, u32), dof: &Dof, scale: f64) -> Option<()> {
|
||||
let g = e.g;
|
||||
let px = w as u64 * h as u64;
|
||||
let limits = g.device.limits();
|
||||
let max = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
|
||||
let prefix_bytes = buffer_bytes((u64::from(w) + 1).checked_mul(u64::from(h))?, 16, max)?;
|
||||
buffer_bytes(1, 16, max)?;
|
||||
if (w as u64 + 1) * h as u64 * 16 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let radius = storage_buf(g, px * 4, "adv3d radius");
|
||||
let mut init = Vec::with_capacity(16);
|
||||
for v in [f32::INFINITY.to_bits(), 0, 0, 0] {
|
||||
|
|
@ -891,12 +701,12 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h):
|
|||
}
|
||||
}
|
||||
let table: Vec<u8> = heads.iter().chain(&spans).flatten().flat_map(|x| x.to_le_bytes()).collect();
|
||||
if table.len() as u64 > max {
|
||||
if table.len() as u64 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let table = e.bytes(table);
|
||||
let src = storage_buf(g, px * 16, "adv3d dof source");
|
||||
let prefix = storage_buf(g, prefix_bytes, "adv3d prefix");
|
||||
let prefix = storage_buf(g, (w as u64 + 1) * h as u64 * 16, "adv3d prefix");
|
||||
let hl = dof.highlight;
|
||||
let b = Binds { bufs: [Some(acc), None, Some(&src), None, Some(&radius), None, None], ..Default::default() };
|
||||
dispatch(e, "boost", [w, h, 0, 0], [hl.gain as f32, hl.threshold as f32, hl.saturation as f32, 0.0], b, pixel_groups(w, h));
|
||||
|
|
@ -934,9 +744,7 @@ pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendere
|
|||
let px = r.width as u64 * r.height as u64;
|
||||
let db = read_buffer(&mut e, &r.depth, px * 4)?;
|
||||
let depth = db
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.chunks_exact(4)
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
|
|
@ -955,8 +763,6 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
return None;
|
||||
}
|
||||
let (w, h) = prep.out;
|
||||
let size = buffer_bytes(u64::from(w).checked_mul(u64::from(h))?, 4, g.device.limits().max_buffer_size)?;
|
||||
let row = usize::try_from(size).ok()?;
|
||||
let mut k = crate::deferred::Key::new(5);
|
||||
k.u64(w as u64);
|
||||
k.u64(h as u64);
|
||||
|
|
@ -985,9 +791,7 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
dst.copy_from_slice(&a.bytes);
|
||||
let depth = b
|
||||
.bytes
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.chunks_exact(4)
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
|
|
@ -1005,6 +809,7 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
let Some(r) = resolve_run(&mut e, prep)? else { return Some(None) };
|
||||
let img = finish(&mut e, &r, !prep.linear, None);
|
||||
let _ = e.read_keyed(&img, ki, [0.0; 2], 1.0);
|
||||
let size = w as u64 * h as u64 * 4;
|
||||
let rb = g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d depth readback"),
|
||||
size,
|
||||
|
|
@ -1014,9 +819,14 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
e.encoder().copy_buffer_to_buffer(&r.depth, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let done = d.start(kz, w, h, [0.0; 2], 1.0);
|
||||
g.map_readback(&rb, row, row, 1, move |r| done(r.map_err(|e| log::error!("gpu depth readback: {e}")).ok()));
|
||||
let b = rb.clone();
|
||||
rb.map_async(wgpu::MapMode::Read, .., move |res| {
|
||||
let out = res.ok().and_then(|_| b.get_mapped_range(..).ok().map(|v| v.to_vec()));
|
||||
b.unmap();
|
||||
done(out);
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = g.poll_readbacks(wgpu::PollType::Poll);
|
||||
let _ = g.device.poll(wgpu::PollType::Poll);
|
||||
d.miss();
|
||||
Some(None)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -259,15 +259,11 @@ pub struct GpuContext {
|
|||
zeros: Mutex<HashMap<(u32, u32), GpuImage>>,
|
||||
/// Advanced 3D render pipelines (built on first use).
|
||||
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
|
||||
/// The adapter can run the Advanced 3D rasteriser (`adv3d::raster_unsupported`); without
|
||||
/// it Advanced 3D scenes render on the CPU.
|
||||
pub(crate) adv3d_raster: bool,
|
||||
/// GPU particle pipeline (built on first use) and simulation checkpoints.
|
||||
pub(crate) particles: crate::particles::PipesCell,
|
||||
pub(crate) particle_states: crate::particles::StatesCell,
|
||||
/// Deferred readbacks (a browser worker's WebGPU device), see [`crate::deferred`].
|
||||
pub(crate) deferred: Option<Arc<crate::deferred::Deferred>>,
|
||||
readbacks: Arc<crate::readback::Readbacks>,
|
||||
}
|
||||
|
||||
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
|
|
@ -292,75 +288,21 @@ fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGro
|
|||
}
|
||||
}
|
||||
|
||||
/// Contain synchronous native initialization errors without replacing the device owner's
|
||||
/// handlers. Scopes are thread-local with wgpu's enabled `std` feature. The pinned native
|
||||
/// wgpu-core backend returns an immediately-ready future from pop_error_scope, so these
|
||||
/// drains do not poll or wait for unrelated submitted GPU work.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn init_resource<T>(device: &wgpu::Device, label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let oom = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
// Some native drivers/wgpu paths can still unwind instead of reporting a scoped error.
|
||||
// Keep the last-resort guard inside the scopes, then drain every scope even on failure.
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(create));
|
||||
let validation_error = pollster::block_on(validation.pop());
|
||||
let internal_error = pollster::block_on(internal.pop());
|
||||
let oom_error = pollster::block_on(oom.pop());
|
||||
if let Some(error) = validation_error.or(internal_error).or(oom_error) {
|
||||
return Err(format!("GPU initialization ({label}): {error}"));
|
||||
}
|
||||
result.map_err(|payload| {
|
||||
let reason =
|
||||
payload.downcast_ref::<String>().map(String::as_str).or_else(|| payload.downcast_ref::<&str>().copied()).unwrap_or("native backend panicked");
|
||||
format!("GPU initialization ({label}): {reason}")
|
||||
})
|
||||
}
|
||||
|
||||
/// Browser scope completion is asynchronous. Preserve the existing browser constructor;
|
||||
/// never block its event loop or introduce a guard across an asynchronous boundary here.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn init_resource<T>(_device: &wgpu::Device, _label: &str, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
Ok(create())
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
/// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the
|
||||
/// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage
|
||||
/// textures) or the device was created with limits below what its pipelines bind.
|
||||
/// Preserves the host's device handlers. The host must forward failures using
|
||||
/// [`Self::device_failure_notifier`] for immediate readback cancellation.
|
||||
/// textures).
|
||||
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<GpuContext, String> {
|
||||
let info = adapter.get_info();
|
||||
if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) {
|
||||
return Err(format!("{} ({:?}): no compute shaders", info.name, info.backend));
|
||||
}
|
||||
// egui-wgpu asks for WebGL2's limits on GL (no storage buffers at all): building the
|
||||
// pipelines there would be a validation error, which release builds only log.
|
||||
let l = device.limits();
|
||||
let needs = [
|
||||
// `adv3d`'s post kernels bind 7 (its rasteriser's fragment stage 6).
|
||||
("max_storage_buffers_per_shader_stage", l.max_storage_buffers_per_shader_stage, 7),
|
||||
("max_storage_textures_per_shader_stage", l.max_storage_textures_per_shader_stage, 1),
|
||||
("max_bind_groups", l.max_bind_groups, 2),
|
||||
("max_compute_workgroup_size_x", l.max_compute_workgroup_size_x, 64),
|
||||
("max_compute_workgroup_size_y", l.max_compute_workgroup_size_y, 16),
|
||||
("max_compute_invocations_per_workgroup", l.max_compute_invocations_per_workgroup, 256),
|
||||
];
|
||||
if let Some((limit, have, need)) = needs.iter().find(|(_, have, need)| have < need) {
|
||||
return Err(format!("{} ({:?}): device limit {limit} is {have}, needs {need}", info.name, info.backend));
|
||||
}
|
||||
for f in [FORMAT, wgpu::TextureFormat::Rgba8Unorm] {
|
||||
if !adapter.get_texture_format_features(f).allowed_usages.contains(wgpu::TextureUsages::STORAGE_BINDING) {
|
||||
return Err(format!("{} ({:?}): {f:?} storage textures unsupported", info.name, info.backend));
|
||||
}
|
||||
}
|
||||
let readbacks = crate::readback::Readbacks::new()?;
|
||||
let name = format!("{} ({:?})", info.name, info.backend);
|
||||
let adv3d_unsupported = crate::adv3d::raster_unsupported(adapter);
|
||||
if let Some(why) = &adv3d_unsupported {
|
||||
log::info!("gpu {name}: Advanced 3D renders on the CPU ({why})");
|
||||
}
|
||||
let src = [
|
||||
include_str!("shaders/common.wgsl"),
|
||||
include_str!("shaders/kernels.wgsl"),
|
||||
|
|
@ -388,61 +330,52 @@ impl GpuContext {
|
|||
include_str!("shaders/sky.wgsl"),
|
||||
]
|
||||
.concat();
|
||||
let module = init_resource(&device, "kernels shader module", || {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) })
|
||||
})?;
|
||||
let bgl = init_resource(&device, "kernels bind-group layout", || {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft kernels"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
let module =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) });
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft kernels"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex_entry(1),
|
||||
tex_entry(2),
|
||||
storage_tex_entry(3, FORMAT),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
tex_entry(1),
|
||||
tex_entry(2),
|
||||
storage_tex_entry(3, FORMAT),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
})?;
|
||||
let layout = init_resource(&device, "kernels pipeline layout", || {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft kernels"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
})
|
||||
})?;
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft kernels"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let storage = |binding: u32| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let bgl_ext = init_resource(&device, "extended kernels bind-group layout", || {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft kernels (ext)"),
|
||||
entries: &[storage(0), storage(1), storage(2), storage(3)],
|
||||
})
|
||||
})?;
|
||||
let layout_ext = init_resource(&device, "extended kernels pipeline layout", || {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft kernels (ext)"),
|
||||
bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)],
|
||||
immediate_size: 0,
|
||||
})
|
||||
})?;
|
||||
let bgl_ext = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft kernels (ext)"),
|
||||
entries: &[storage(0), storage(1), storage(2), storage(3)],
|
||||
});
|
||||
let layout_ext = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft kernels (ext)"),
|
||||
bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipelines = ENTRIES
|
||||
.iter()
|
||||
.chain(crate::fx_color::KERNELS)
|
||||
|
|
@ -469,64 +402,50 @@ impl GpuContext {
|
|||
.map(|e| (e, &layout))
|
||||
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
|
||||
.map(|(e, layout)| {
|
||||
let p = init_resource(&device, e, || {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(e),
|
||||
layout: Some(layout),
|
||||
module: &module,
|
||||
entry_point: Some(e),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
})?;
|
||||
Ok((*e, p))
|
||||
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(e),
|
||||
layout: Some(layout),
|
||||
module: &module,
|
||||
entry_point: Some(e),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(*e, p)
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, String>>()?;
|
||||
let dmodule = init_resource(&device, "display shader module", || {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/display.wgsl").into()),
|
||||
})
|
||||
})?;
|
||||
let display_bgl = init_resource(&device, "display bind-group layout", || {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
entries: &[tex_entry(1), storage_tex_entry(3, wgpu::TextureFormat::Rgba8Unorm)],
|
||||
})
|
||||
})?;
|
||||
let dlayout = init_resource(&device, "display pipeline layout", || {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
bind_group_layouts: &[Some(&display_bgl)],
|
||||
immediate_size: 0,
|
||||
})
|
||||
})?;
|
||||
let display = init_resource(&device, "display compute pipeline", || {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("display"),
|
||||
layout: Some(&dlayout),
|
||||
module: &dmodule,
|
||||
entry_point: Some("display"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
})?;
|
||||
let dummy = init_resource(&device, "dummy texture", || {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dummy"),
|
||||
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
})?;
|
||||
let dummy_buf = init_resource(&device, "dummy storage buffer", || {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE })
|
||||
})?;
|
||||
let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?;
|
||||
.collect();
|
||||
let dmodule = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/display.wgsl").into()),
|
||||
});
|
||||
let display_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
entries: &[tex_entry(1), storage_tex_entry(3, wgpu::TextureFormat::Rgba8Unorm)],
|
||||
});
|
||||
let dlayout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("effectcraft display"),
|
||||
bind_group_layouts: &[Some(&display_bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let display = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("display"),
|
||||
layout: Some(&dlayout),
|
||||
module: &dmodule,
|
||||
entry_point: Some("display"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let dummy = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("dummy"),
|
||||
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let dummy_buf =
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE });
|
||||
let max_dim = device.limits().max_texture_dimension_2d;
|
||||
Ok(GpuContext {
|
||||
device,
|
||||
|
|
@ -538,7 +457,7 @@ impl GpuContext {
|
|||
pipelines,
|
||||
display_bgl,
|
||||
display,
|
||||
dummy_tex,
|
||||
dummy_tex: dummy.create_view(&Default::default()),
|
||||
dummy,
|
||||
dummy_buf,
|
||||
uploads: Mutex::new(Uploads::default()),
|
||||
|
|
@ -547,116 +466,14 @@ impl GpuContext {
|
|||
transfers: Default::default(),
|
||||
zeros: Default::default(),
|
||||
adv3d: std::sync::OnceLock::new(),
|
||||
adv3d_raster: adv3d_unsupported.is_none(),
|
||||
particles: Default::default(),
|
||||
particle_states: Default::default(),
|
||||
deferred: None,
|
||||
readbacks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this context has observed a readback timeout or device failure.
|
||||
/// A successful check is not a guarantee against faults after it returns.
|
||||
/// Shared-device hosts must forward their device failures through the notifier.
|
||||
pub fn check_health(&self) -> Result<(), String> {
|
||||
self.readbacks.check()
|
||||
}
|
||||
|
||||
/// A notification hook for the owner of a shared device's error/loss handlers.
|
||||
/// Invoke it with diagnostic context when the host observes a device failure.
|
||||
/// It retires this context and settles pending readbacks without replacing any
|
||||
/// host handler or keeping this context/device alive. It is harmless after drop.
|
||||
///
|
||||
/// Shared-device construction does not automatically subscribe to device faults:
|
||||
/// without host forwarding, readbacks rely on poll errors and their deadlines.
|
||||
pub fn device_failure_notifier(&self) -> impl Fn(String) + Send + Sync + 'static {
|
||||
crate::readback::failure_notifier(&self.readbacks)
|
||||
}
|
||||
|
||||
/// All device polling routes through this failure bridge.
|
||||
pub(crate) fn poll_readbacks(&self, kind: wgpu::PollType) -> Result<wgpu::PollStatus, String> {
|
||||
self.readbacks.check()?;
|
||||
self.device.poll(kind).map_err(|e| {
|
||||
let error = format!("GPU readback poll: {e}");
|
||||
self.readbacks.retire(error.clone());
|
||||
log::error!("{error}");
|
||||
error
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a bounded buffer mapping. Retired contexts reject new requests immediately.
|
||||
pub(crate) fn map_readback(
|
||||
&self,
|
||||
buffer: &wgpu::Buffer,
|
||||
row: usize,
|
||||
stride: usize,
|
||||
height: usize,
|
||||
done: impl FnOnce(Result<Vec<u8>, String>) + wgpu::WasmNotSend + 'static,
|
||||
) {
|
||||
let ticket = match self.readbacks.start(done) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
log::error!("gpu: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !ticket.active() {
|
||||
return;
|
||||
}
|
||||
let b = buffer.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
if !ticket.active() {
|
||||
b.unmap();
|
||||
return;
|
||||
}
|
||||
let result = r.map_err(|e| format!("GPU buffer mapping: {e}")).and_then(|_| {
|
||||
let view = b.get_mapped_range(..).map_err(|e| format!("GPU mapped range: {e}"))?;
|
||||
crate::readback::packed(&view, row, stride, height)
|
||||
});
|
||||
b.unmap();
|
||||
ticket.complete(result);
|
||||
});
|
||||
}
|
||||
|
||||
/// A bounded native readback: polling and receiving share one deadline.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result<Vec<u8>, String> {
|
||||
let deadline = std::time::Instant::now() + crate::readback::TIMEOUT;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.map_readback(buffer, row, stride, height, move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
loop {
|
||||
self.readbacks.check()?;
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
let error = "GPU readback receive timed out; context retired".to_string();
|
||||
self.readbacks.retire(error.clone());
|
||||
return Err(error);
|
||||
}
|
||||
// Block until the GPU is done (bounded by the deadline: a timeout retires the
|
||||
// context) rather than polling and sleeping, which adds latency to every readback.
|
||||
self.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(remaining) })?;
|
||||
// The callback may be running on another thread that polled the same device.
|
||||
match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) {
|
||||
Ok(result) => return result,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(e) => return Err(format!("GPU readback completion channel: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn read_buffer(&self, _buffer: &wgpu::Buffer, _row: usize, _stride: usize, _height: usize) -> Result<Vec<u8>, String> {
|
||||
Err("synchronous GPU readback is unavailable in the browser".into())
|
||||
}
|
||||
|
||||
/// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for.
|
||||
pub fn set_deferred(&mut self, on: bool) {
|
||||
self.readbacks.cancel_pending("GPU readback mode changed".into());
|
||||
if let Some(d) = self.deferred.take() {
|
||||
d.cancel();
|
||||
}
|
||||
self.deferred = on.then(Default::default);
|
||||
}
|
||||
|
||||
|
|
@ -669,12 +486,6 @@ impl GpuContext {
|
|||
pub async fn request() -> Result<GpuContext, String> {
|
||||
#[allow(unused_mut)]
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// Match eframe's backend override so CLI and headless acceptance tests
|
||||
// exercise the backend requested for the native viewer.
|
||||
desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
|
||||
|
|
@ -694,10 +505,17 @@ impl GpuContext {
|
|||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
let context = GpuContext::new(&adapter, device, queue)?;
|
||||
// This path owns the device; install handlers only after construction succeeds.
|
||||
crate::readback::install(&context.device, &context.readbacks);
|
||||
Ok(context)
|
||||
device.on_uncaptured_error(Arc::new(|e| {
|
||||
// Under test a validation error (a WGSL typo invalidates every kernel) fails loudly.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)] // test builds only
|
||||
{
|
||||
panic!("wgpu: {e}")
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
log::error!("wgpu: {e}")
|
||||
}));
|
||||
GpuContext::new(&adapter, device, queue)
|
||||
}
|
||||
|
||||
/// A device of its own on the best adapter (CLI, tests, benchmarks). `None` when no
|
||||
|
|
@ -710,13 +528,13 @@ impl GpuContext {
|
|||
/// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser
|
||||
/// worker, [`crate::deferred`]). Impossible on the browser's main thread.
|
||||
pub fn can_readback(&self) -> bool {
|
||||
self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some())
|
||||
self.can_wait() || self.deferred.is_some()
|
||||
}
|
||||
|
||||
/// Readbacks can be waited for (natively, unless deferred). Steps that read back without a
|
||||
/// key ([`crate::deferred`]) need this.
|
||||
pub fn can_wait(&self) -> bool {
|
||||
self.readbacks.check().is_ok() && cfg!(not(target_arch = "wasm32")) && self.deferred.is_none()
|
||||
cfg!(not(target_arch = "wasm32")) && self.deferred.is_none()
|
||||
}
|
||||
|
||||
pub(crate) fn fits(&self, w: u32, h: u32) -> bool {
|
||||
|
|
@ -1018,13 +836,7 @@ impl<'g> Enc<'g> {
|
|||
|
||||
/// Read an image back to the CPU (submits and waits). `None` where readback is impossible.
|
||||
pub fn download(&mut self, img: &GpuImage) -> Option<Image> {
|
||||
let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size)
|
||||
.map_err(|e| log::error!("gpu image readback: {e}"))
|
||||
.ok()?;
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(layout.len / 16).map_err(|e| log::error!("gpu image allocation: {e}")).ok()?;
|
||||
data.resize(layout.len / 16, [0.0; 4]);
|
||||
let mut out = Image { width: img.width, height: img.height, data };
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data);
|
||||
self.read_texture_into(&img.texture, img.width, img.height, 16, dst)?;
|
||||
Some(out)
|
||||
|
|
@ -1032,11 +844,7 @@ impl<'g> Enc<'g> {
|
|||
|
||||
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
|
||||
pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
|
||||
let layout =
|
||||
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?;
|
||||
out.resize(layout.len, 0);
|
||||
let mut out = vec![0u8; (w * bpp) as usize * h as usize];
|
||||
self.read_texture_into(texture, w, h, bpp, &mut out)?;
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -1047,15 +855,9 @@ impl<'g> Enc<'g> {
|
|||
return None;
|
||||
}
|
||||
self.g.transfers.count(false, out.len());
|
||||
let layout =
|
||||
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
|
||||
if out.len() != layout.len {
|
||||
log::error!("gpu readback destination size mismatch");
|
||||
return None;
|
||||
}
|
||||
let row = layout.row as usize;
|
||||
let padded = layout.stride;
|
||||
let size = layout.size;
|
||||
let row = (w * bpp) as usize;
|
||||
let padded = (w * bpp).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let size = padded as u64 * h as u64;
|
||||
let buf = self.g.staging.lock().ok().and_then(|mut s| s.get_mut(&size).and_then(Vec::pop)).unwrap_or_else(|| {
|
||||
self.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback"),
|
||||
|
|
@ -1070,12 +872,27 @@ impl<'g> Enc<'g> {
|
|||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
self.submit();
|
||||
let bytes = self.g.read_buffer(&buf, row, padded as usize, h as usize).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
|
||||
if bytes.len() != out.len() {
|
||||
log::error!("gpu texture readback size mismatch");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
buf.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
if let Err(e) = self.g.device.poll(wgpu::PollType::wait_indefinitely()) {
|
||||
log::error!("gpu: poll: {e}");
|
||||
return None;
|
||||
}
|
||||
out.copy_from_slice(&bytes);
|
||||
rx.recv().ok()?.ok()?;
|
||||
{
|
||||
let view = buf.get_mapped_range(..).ok()?;
|
||||
if row == padded as usize {
|
||||
out.copy_from_slice(&view[..out.len()]);
|
||||
} else {
|
||||
for (y, dst) in out.chunks_exact_mut(row).enumerate() {
|
||||
let s = y * padded as usize;
|
||||
dst.copy_from_slice(&view[s..s + row]);
|
||||
}
|
||||
}
|
||||
}
|
||||
buf.unmap();
|
||||
// Keep a few staging buffers per size (frames read back at the same size every time).
|
||||
if let Ok(mut s) = self.g.staging.lock() {
|
||||
let v = s.entry(size).or_default();
|
||||
|
|
@ -1090,24 +907,11 @@ impl<'g> Enc<'g> {
|
|||
/// gets the bytes once the copy is mapped (from the browser's event loop on the web; from
|
||||
/// a later device poll natively).
|
||||
pub fn read_texture_async(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
if let Err(e) = self.g.readbacks.check() {
|
||||
log::error!("gpu readback: {e}");
|
||||
crate::readback::reject(done, e);
|
||||
return;
|
||||
}
|
||||
let layout = match crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size) {
|
||||
Ok(layout) => layout,
|
||||
Err(e) => {
|
||||
log::error!("gpu texture readback: {e}");
|
||||
crate::readback::reject(done, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let row = layout.row;
|
||||
let padded = layout.stride;
|
||||
let row = w * bpp;
|
||||
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback-async"),
|
||||
size: layout.size,
|
||||
size: padded as u64 * h as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
|
@ -1117,11 +921,21 @@ impl<'g> Enc<'g> {
|
|||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
self.submit();
|
||||
self.g.map_readback(&buf, row as usize, padded as usize, h as usize, move |r| {
|
||||
done(r.map_err(|e| log::error!("gpu texture readback: {e}")).ok());
|
||||
let b = buf.clone();
|
||||
buf.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let out = r.ok().and_then(|_| {
|
||||
let view = b.get_mapped_range(..).ok()?;
|
||||
let mut out = Vec::with_capacity((row * h) as usize);
|
||||
for y in 0..h as usize {
|
||||
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
|
||||
}
|
||||
Some(out)
|
||||
});
|
||||
b.unmap();
|
||||
done(out);
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = self.g.poll_readbacks(wgpu::PollType::Poll);
|
||||
let _ = self.g.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// A deferred readback of `img` (RGBA f32) under `key` (see [`crate::deferred`]): the bytes
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
//! of its key, and a chain whose kernels read other data through the effect host (other layers,
|
||||
//! other times) is not started when that data missed while it ran (the miss counter moved).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::pin::Pin;
|
||||
|
|
@ -48,9 +48,11 @@ pub(crate) enum Lookup {
|
|||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Frame generation: results of an older frame's readbacks are dropped.
|
||||
generation: u64,
|
||||
ready: HashMap<u128, Option<Readback>>,
|
||||
/// Each request owns a unique token, even when a key is reused after cancellation.
|
||||
waiting: HashMap<u128, Arc<()>>,
|
||||
waiting: HashSet<u128>,
|
||||
in_flight: usize,
|
||||
/// Misses so far in this frame (a counter, so a chain sees misses during its own work).
|
||||
misses: u64,
|
||||
wakers: Vec<Waker>,
|
||||
|
|
@ -69,15 +71,11 @@ pub struct Deferred {
|
|||
|
||||
impl Deferred {
|
||||
pub fn frame_begin(&self) {
|
||||
let wakers = {
|
||||
let mut s = self.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Ok(mut s) = self.st.lock() {
|
||||
s.generation += 1;
|
||||
s.ready.clear();
|
||||
s.waiting.clear();
|
||||
s.misses = 0;
|
||||
std::mem::take(&mut s.wakers)
|
||||
};
|
||||
for w in wakers {
|
||||
w.wake();
|
||||
}
|
||||
self.gate.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
|
@ -113,25 +111,32 @@ impl Deferred {
|
|||
if let Some(r) = s.ready.get(&key) {
|
||||
return Lookup::Ready(r.clone());
|
||||
}
|
||||
if s.waiting.contains_key(&key) { Lookup::InFlight } else { Lookup::Absent }
|
||||
if s.waiting.contains(&key) { Lookup::InFlight } else { Lookup::Absent }
|
||||
}
|
||||
|
||||
/// A readback for `key` is being started; returns the callback that stores its bytes.
|
||||
pub(crate) fn start(self: &Arc<Self>, key: u128, width: u32, height: u32, offset: [f64; 2], scale: f64) -> impl FnOnce(Option<Vec<u8>>) + 'static {
|
||||
let token = Arc::new(());
|
||||
self.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner).waiting.insert(key, token.clone());
|
||||
let generation = match self.st.lock() {
|
||||
Ok(mut s) => {
|
||||
s.waiting.insert(key);
|
||||
s.in_flight += 1;
|
||||
s.generation
|
||||
}
|
||||
Err(_) => 0,
|
||||
};
|
||||
self.readbacks.fetch_add(1, Ordering::Relaxed);
|
||||
let me = self.clone();
|
||||
move |bytes: Option<Vec<u8>>| {
|
||||
let wakers = {
|
||||
let mut s = me.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Reject stale completion before touching either results or accounting.
|
||||
if !s.waiting.get(&key).is_some_and(|current| Arc::ptr_eq(current, &token)) {
|
||||
return;
|
||||
let wakers = match me.st.lock() {
|
||||
Ok(mut s) => {
|
||||
s.in_flight = s.in_flight.saturating_sub(1);
|
||||
if s.generation == generation {
|
||||
s.waiting.remove(&key);
|
||||
s.ready.insert(key, bytes.map(|bytes| Readback { bytes, width, height, offset, scale }));
|
||||
}
|
||||
if s.in_flight == 0 { std::mem::take(&mut s.wakers) } else { vec![] }
|
||||
}
|
||||
s.waiting.remove(&key);
|
||||
s.ready.insert(key, bytes.map(|bytes| Readback { bytes, width, height, offset, scale }));
|
||||
if s.waiting.is_empty() { std::mem::take(&mut s.wakers) } else { vec![] }
|
||||
Err(_) => vec![],
|
||||
};
|
||||
for w in wakers {
|
||||
w.wake();
|
||||
|
|
@ -139,24 +144,9 @@ impl Deferred {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cancel pending requests without exposing placeholder pixels as successful results.
|
||||
/// Existing waiters wake; late callbacks cannot change a newer request's accounting.
|
||||
pub(crate) fn cancel(&self) {
|
||||
let wakers = {
|
||||
let mut s = self.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for (key, _) in std::mem::take(&mut s.waiting) {
|
||||
s.ready.insert(key, None);
|
||||
}
|
||||
std::mem::take(&mut s.wakers)
|
||||
};
|
||||
for w in wakers {
|
||||
w.wake();
|
||||
}
|
||||
}
|
||||
|
||||
/// Readbacks still in flight.
|
||||
pub fn in_flight(&self) -> usize {
|
||||
self.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner).waiting.len()
|
||||
self.st.lock().map(|s| s.in_flight).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Resolves once no readback is in flight (on the web the browser's event loop delivers
|
||||
|
|
@ -172,13 +162,11 @@ pub struct Settled(Arc<Deferred>);
|
|||
impl Future for Settled {
|
||||
type Output = ();
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let mut s = self.0.st.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if s.waiting.is_empty() {
|
||||
let Ok(mut s) = self.0.st.lock() else { return Poll::Ready(()) };
|
||||
if s.in_flight == 0 {
|
||||
return Poll::Ready(());
|
||||
}
|
||||
if !s.wakers.iter().any(|w| w.will_wake(cx.waker())) {
|
||||
s.wakers.push(cx.waker().clone());
|
||||
}
|
||||
s.wakers.push(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
|
@ -261,56 +249,6 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> u12
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct WakeCount(std::sync::atomic::AtomicUsize);
|
||||
impl std::task::Wake for WakeCount {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_wakes_waiters_and_stale_same_key_does_not_drain_new_work() {
|
||||
let d = Arc::new(Deferred::default());
|
||||
let old = d.start(7, 1, 1, [0.0; 2], 1.0);
|
||||
let wake = Arc::new(WakeCount(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let waker = Waker::from(wake.clone());
|
||||
let mut settled = std::pin::pin!(d.settled());
|
||||
for _ in 0..4 {
|
||||
assert!(settled.as_mut().poll(&mut Context::from_waker(&waker)).is_pending());
|
||||
}
|
||||
d.cancel();
|
||||
assert_eq!(wake.0.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(d.in_flight(), 0);
|
||||
assert!(settled.as_mut().poll(&mut Context::from_waker(&waker)).is_ready());
|
||||
assert!(matches!(d.lookup(7), Lookup::Ready(None)));
|
||||
d.frame_begin();
|
||||
let new = d.start(7, 1, 1, [0.0; 2], 1.0);
|
||||
old(Some(vec![99; 4]));
|
||||
assert_eq!(d.in_flight(), 1);
|
||||
assert!(matches!(d.lookup(7), Lookup::InFlight));
|
||||
new(None);
|
||||
assert_eq!(d.in_flight(), 0);
|
||||
assert!(matches!(d.lookup(7), Lookup::Ready(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_failure_without_map_callback_drains_and_wakes_deferred() {
|
||||
let d = Arc::new(Deferred::default());
|
||||
let registry = crate::readback::Readbacks::new().unwrap();
|
||||
let done = d.start(11, 1, 1, [0.0; 2], 1.0);
|
||||
let ticket = registry.start(move |r| done(r.ok())).unwrap();
|
||||
let wake = Arc::new(WakeCount(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let waker = Waker::from(wake.clone());
|
||||
let mut settled = std::pin::pin!(d.settled());
|
||||
assert!(settled.as_mut().poll(&mut Context::from_waker(&waker)).is_pending());
|
||||
registry.retire("synthetic device loss".into());
|
||||
assert_eq!(wake.0.load(Ordering::SeqCst), 1);
|
||||
assert!(settled.as_mut().poll(&mut Context::from_waker(&waker)).is_ready());
|
||||
assert_eq!(d.in_flight(), 0);
|
||||
ticket.complete(Ok(vec![99; 4]));
|
||||
assert!(matches!(d.lookup(11), Lookup::Ready(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_stable_and_distinct() {
|
||||
let mut a = Key::new(1);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ mod fx_vr;
|
|||
mod fx_warp;
|
||||
mod ops;
|
||||
mod particles;
|
||||
mod readback;
|
||||
mod walk;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -158,7 +157,7 @@ impl Gpu {
|
|||
/// Deliver finished readbacks without blocking (native; the browser delivers them itself).
|
||||
pub fn poll(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = self.ctx.poll_readbacks(wgpu::PollType::Poll);
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// `{passes, readbacks, inFlight}` of deferred rendering (diagnostics).
|
||||
|
|
@ -178,14 +177,10 @@ impl Gpu {
|
|||
/// Render a top-level comp frame and leave it on the GPU as a display texture (no
|
||||
/// readback unless a CPU fallback step needs one). `None` = render on the CPU.
|
||||
pub fn render_display(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<DisplayFrame> {
|
||||
self.ctx.check_health().map_err(|e| log::error!("gpu display: {e}")).ok()?;
|
||||
let mut e = Enc::new(&self.ctx);
|
||||
let img = walk::render(&mut e, r, comp, t)?;
|
||||
let texture = e.display(&img);
|
||||
e.submit();
|
||||
// Reject failures observed during rendering/submission before publishing the frame.
|
||||
// A later device fault still requires the presentation host's lifecycle handling.
|
||||
self.ctx.check_health().map_err(|e| log::error!("gpu display: {e}")).ok()?;
|
||||
Some(DisplayFrame { texture, width: img.width, height: img.height })
|
||||
}
|
||||
|
||||
|
|
@ -197,17 +192,11 @@ impl Gpu {
|
|||
/// [`Gpu::read_display`] without blocking (the browser's main thread): `done` gets the bytes
|
||||
/// once the GPU has them.
|
||||
pub fn read_display_async(&self, f: &DisplayFrame, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
if let Err(e) = self.ctx.check_health() {
|
||||
log::error!("gpu display readback: {e}");
|
||||
crate::readback::reject(done, e);
|
||||
return;
|
||||
}
|
||||
Enc::new(&self.ctx).read_texture_async(&f.texture, f.width, f.height, 4, done);
|
||||
}
|
||||
|
||||
/// Render a frame on the GPU and read it back (`None` = not handled).
|
||||
pub fn render(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
self.ctx.check_health().ok()?;
|
||||
if !self.ctx.can_readback() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -255,34 +244,9 @@ impl Gpu {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run work in a native thread-local out-of-memory scope. A failed result is discarded
|
||||
/// and cached allocations are released; callers can retry with a fresh CPU renderer.
|
||||
/// Browser callers use the host/owned-device failure bridge and bounded readbacks instead
|
||||
/// of blocking their event loop on an error-scope future.
|
||||
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
self.ctx.check_health()?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let out = f();
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
self.ctx.clear_uploads();
|
||||
Err(e.to_string())
|
||||
}
|
||||
None => self.ctx.check_health().map(|()| out),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let out = f();
|
||||
self.ctx.check_health().map(|()| out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for submitted GPU work with the readback deadline (benchmarks).
|
||||
/// Wait for all submitted GPU work (benchmarks).
|
||||
pub fn wait(&self) {
|
||||
let _ = self.ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(crate::readback::TIMEOUT) });
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -296,26 +260,22 @@ impl Accelerator for Gpu {
|
|||
}
|
||||
|
||||
fn supports_effect(&self, id: &str) -> bool {
|
||||
self.ctx.check_health().is_ok() && self.ctx.can_readback() && effects::supports(id)
|
||||
self.ctx.can_readback() && effects::supports(id)
|
||||
}
|
||||
|
||||
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
|
||||
self.ctx.check_health().ok()?;
|
||||
effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels)
|
||||
}
|
||||
|
||||
fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option<effectcraft_render::three_d::adv::Target> {
|
||||
self.ctx.check_health().ok()?;
|
||||
adv3d::render(&self.ctx, scene)
|
||||
}
|
||||
|
||||
fn render_3d(&self, run: &effectcraft_render::three_d::adv::Prepared) -> Option<effectcraft_render::three_d::adv::Rendered> {
|
||||
self.ctx.check_health().ok()?;
|
||||
adv3d::render_prepared(&self.ctx, run)
|
||||
}
|
||||
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
self.ctx.check_health().ok()?;
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
|
|
@ -348,7 +308,7 @@ impl Accelerator for Gpu {
|
|||
// Natively the device delivers the readbacks when polled: wait for them here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if d.in_flight() > 0 {
|
||||
let _ = self.ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(crate::readback::TIMEOUT) });
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
Some(Box::pin(async move { d.settled().await }))
|
||||
}
|
||||
|
|
@ -356,7 +316,6 @@ impl Accelerator for Gpu {
|
|||
|
||||
impl effectcraft_effects::psim::ParticleSim for Gpu {
|
||||
fn simulate(&self, req: &effectcraft_effects::psim::SimRequest) -> Option<Vec<effectcraft_effects::psim::SimParticle>> {
|
||||
self.ctx.check_health().ok()?;
|
||||
particles::simulate(&self.ctx, req)
|
||||
}
|
||||
}
|
||||
|
|
@ -413,7 +372,5 @@ mod tests_fx_transition;
|
|||
mod tests_fx_vr;
|
||||
#[cfg(test)]
|
||||
mod tests_fx_warp;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_init;
|
||||
#[cfg(test)]
|
||||
mod tests_particles;
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ pub fn warp_q(
|
|||
p.f[0] = r[0];
|
||||
p.f[1] = r[1];
|
||||
p.f[2] = r[2];
|
||||
p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v)];
|
||||
p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), 0.0];
|
||||
p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32];
|
||||
let mut out = e.scratch(dst.width, dst.height);
|
||||
e.pixels("warp_blend", &p, &img, Some(dst), &out, None);
|
||||
|
|
@ -110,7 +110,7 @@ pub fn warp_q(
|
|||
pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32, levels: Option<f32>) -> GpuImage {
|
||||
let mut p = Params::default();
|
||||
p.u[0] = [mode_id(mode), 0, seed, 0];
|
||||
p.f[0] = [opacity, levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v), 0.0];
|
||||
p.f[0] = [opacity, levels.unwrap_or(0.0), 0.0, 0.0];
|
||||
let mut out = e.scratch(dst.width, dst.height);
|
||||
e.pixels("blend_full", &p, src, Some(dst), &out, None);
|
||||
out.levels = levels;
|
||||
|
|
@ -174,7 +174,6 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
|
|||
}
|
||||
let mut p = Params::default();
|
||||
p.f[0][0] = levels;
|
||||
p.f[0][1] = 1.0 / levels;
|
||||
let mut out = e.scratch(img.width, img.height);
|
||||
e.pixels("quantize", &p, img, None, &out, None);
|
||||
out.levels = Some(levels);
|
||||
|
|
|
|||
|
|
@ -193,18 +193,29 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimPartic
|
|||
enc.copy_buffer_to_buffer(&next, 0, &read, 0, size);
|
||||
g.queue.submit([enc.finish()]);
|
||||
if let (Some(d), Some(k)) = (&deferred, key) {
|
||||
let row = usize::try_from(size).ok()?;
|
||||
let done = d.start(k, n, 1, [0.0; 2], 1.0);
|
||||
g.map_readback(&read, row, row, 1, move |r| done(r.map_err(|e| log::error!("gpu particle readback: {e}")).ok()));
|
||||
let b = read.clone();
|
||||
read.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let out = r.ok().and_then(|_| b.get_mapped_range(..).ok().map(|v| v.to_vec()));
|
||||
b.unmap();
|
||||
done(out);
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = g.poll_readbacks(wgpu::PollType::Poll);
|
||||
let _ = g.device.poll(wgpu::PollType::Poll);
|
||||
keep_checkpoint(g, req, n, next);
|
||||
d.miss();
|
||||
return Some(vec![]);
|
||||
}
|
||||
let row = usize::try_from(size).ok()?;
|
||||
let bytes = g.read_buffer(&read, row, row, 1).map_err(|e| log::error!("gpu particle readback: {e}")).ok()?;
|
||||
let parts = parse(&bytes);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
read.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
|
||||
rx.recv().ok()?.ok()?;
|
||||
let view = read.get_mapped_range(..).ok()?;
|
||||
let parts = parse(&view);
|
||||
drop(view);
|
||||
read.unmap();
|
||||
keep_checkpoint(g, req, n, next);
|
||||
Some(parts)
|
||||
}
|
||||
|
|
@ -222,10 +233,8 @@ fn sim_key(req: &SimRequest) -> u128 {
|
|||
|
||||
/// Live particles of a state buffer's bytes.
|
||||
fn parse(bytes: &[u8]) -> Vec<SimParticle> {
|
||||
let f: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
|
||||
f.as_chunks::<12>()
|
||||
.0
|
||||
.iter()
|
||||
let f: Vec<f32> = bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
|
||||
f.chunks_exact(12)
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s[9] != 0.0)
|
||||
.map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 })
|
||||
|
|
|
|||
|
|
@ -1,529 +0,0 @@
|
|||
//! Bounded, exactly-once readback completion, independent of GPU callbacks.
|
||||
// Browser callbacks stay on their worker thread. The workspace's wgpu feature still
|
||||
// requires Send callbacks on non-atomic wasm, and ParticleSim requires Send + Sync.
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub(crate) type Error = String;
|
||||
type Done = Box<dyn FnOnce(Result<Vec<u8>, Error>) + Send>;
|
||||
|
||||
struct Entry {
|
||||
deadline: web_time::Instant,
|
||||
done: Done,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
next: u64,
|
||||
pending: BTreeMap<u64, Entry>,
|
||||
retired: Option<Error>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
timer_armed: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Readbacks(Mutex<State>);
|
||||
pub(crate) struct Ticket {
|
||||
owner: Weak<Readbacks>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
/// User callbacks run outside locks and are isolated one at a time. On panic=abort
|
||||
/// (including the usual wasm build), Rust panics cannot be caught: the host must
|
||||
/// avoid panicking callbacks. This branch does not claim abort-mode recovery.
|
||||
fn notify(done: Done, result: Result<Vec<u8>, Error>) {
|
||||
#[cfg(panic = "unwind")]
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| done(result))).is_err() {
|
||||
log::error!("GPU readback callback panicked; remaining callbacks will still settle");
|
||||
}
|
||||
#[cfg(not(panic = "unwind"))]
|
||||
done(result);
|
||||
}
|
||||
|
||||
/// Reject an optional-pixel callback through the same isolation boundary as mapped results.
|
||||
pub(crate) fn reject(done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static, error: Error) {
|
||||
notify(Box::new(move |result| done(result.ok())), Err(error));
|
||||
}
|
||||
|
||||
impl Readbacks {
|
||||
pub(crate) fn new() -> Result<Arc<Self>, Error> {
|
||||
let me = Arc::new(Self::default());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let weak = Arc::downgrade(&me);
|
||||
std::thread::Builder::new()
|
||||
.name("gpu-readback-deadlines".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let Some(me) = weak.upgrade() else { break };
|
||||
me.expire(web_time::Instant::now());
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("starting GPU readback deadline worker: {e}"))?;
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self) -> Result<(), Error> {
|
||||
let s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
match &s.retired {
|
||||
Some(e) => Err(e.clone()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, done: impl FnOnce(Result<Vec<u8>, Error>) + wgpu::WasmNotSend + 'static) -> Result<Ticket, Error> {
|
||||
let mut done: Option<Done> = Some(Box::new(done));
|
||||
let result = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(e) = &s.retired {
|
||||
Err(e.clone())
|
||||
} else if s.pending.len() >= 256 {
|
||||
Err("too many pending GPU readbacks".into())
|
||||
} else if let Some(id) = s.next.checked_add(1) {
|
||||
s.next = id;
|
||||
if let Some(done) = done.take() {
|
||||
s.pending.insert(id, Entry { deadline: web_time::Instant::now() + TIMEOUT, done });
|
||||
}
|
||||
Ok(Ticket { owner: Arc::downgrade(self), id })
|
||||
} else {
|
||||
Err("GPU readback request id exhausted".into())
|
||||
}
|
||||
};
|
||||
if let Err(e) = &result
|
||||
&& let Some(done) = done
|
||||
{
|
||||
notify(done, Err(e.clone()));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if result.is_ok() {
|
||||
self.arm_timer();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn arm_timer(self: &Arc<Self>) {
|
||||
let delay = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if s.timer_armed || s.retired.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(deadline) = s.pending.values().map(|e| e.deadline).min() else {
|
||||
return;
|
||||
};
|
||||
s.timer_armed = true;
|
||||
deadline.saturating_duration_since(web_time::Instant::now())
|
||||
};
|
||||
if let Err(e) = schedule(Arc::downgrade(self), delay) {
|
||||
self.retire(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn retire(&self, error: Error) {
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if s.retired.is_some() {
|
||||
return;
|
||||
}
|
||||
s.retired = Some(error.clone());
|
||||
std::mem::take(&mut s.pending)
|
||||
};
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_pending(&self, error: Error) {
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
std::mem::take(&mut s.pending)
|
||||
};
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn expire(&self, now: web_time::Instant) {
|
||||
let error = "GPU readback timed out; context retired".to_string();
|
||||
let pending = {
|
||||
let mut s = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Decide and drain atomically: a cancelled old deadline must not retire
|
||||
// a newer request inserted between the deadline check and retirement.
|
||||
if s.retired.is_some() || !s.pending.values().any(|e| now >= e.deadline) {
|
||||
return;
|
||||
}
|
||||
s.retired = Some(error.clone());
|
||||
std::mem::take(&mut s.pending)
|
||||
};
|
||||
for (_, entry) in pending {
|
||||
notify(entry.done, Err(error.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Readbacks {
|
||||
fn drop(&mut self) {
|
||||
self.retire("GPU readback context dropped".into());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Layout {
|
||||
pub row: u32,
|
||||
pub stride: u32,
|
||||
pub size: u64,
|
||||
pub len: usize,
|
||||
}
|
||||
impl Layout {
|
||||
pub(crate) fn new(w: u32, h: u32, bpp: u32, limit: u64) -> Result<Self, Error> {
|
||||
if w == 0 || h == 0 || bpp == 0 {
|
||||
return Err("empty GPU texture readback".into());
|
||||
}
|
||||
let row = w.checked_mul(bpp).ok_or("GPU readback row size overflow")?;
|
||||
let alignment = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let stride = row.checked_add(alignment - 1).map(|n| n / alignment * alignment).ok_or("GPU readback row alignment overflow")?;
|
||||
let size = u64::from(stride) * u64::from(h);
|
||||
// Bound host allocations even when the adapter advertises much larger buffers.
|
||||
if size > limit.min(1 << 30) {
|
||||
return Err("GPU readback exceeds buffer budget".into());
|
||||
}
|
||||
let len = usize::try_from(u64::from(row) * u64::from(h)).map_err(|e| format!("GPU readback host size: {e}"))?;
|
||||
Ok(Self { row, stride, size, len })
|
||||
}
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.owner.upgrade().is_some_and(|me| me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.contains_key(&self.id))
|
||||
}
|
||||
pub(crate) fn complete(self, result: Result<Vec<u8>, Error>) {
|
||||
let Some(me) = self.owner.upgrade() else { return };
|
||||
let entry = me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pending.remove(&self.id);
|
||||
if let Some(entry) = entry {
|
||||
notify(entry.done, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn schedule(me: Weak<Readbacks>, delay: Duration) -> Result<(), Error> {
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
let global = js_sys::global();
|
||||
let function = js_sys::Reflect::get(&global, &"setTimeout".into())
|
||||
.map_err(|e| format!("GPU deadline timer lookup: {e:?}"))?
|
||||
.dyn_into::<js_sys::Function>()
|
||||
.map_err(|_| "GPU deadline timer unavailable".to_string())?;
|
||||
let callback = Closure::once(move || {
|
||||
if let Some(me) = me.upgrade() {
|
||||
me.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).timer_armed = false;
|
||||
me.expire(web_time::Instant::now());
|
||||
// If the earliest request completed, arm for the next live deadline.
|
||||
me.arm_timer();
|
||||
}
|
||||
});
|
||||
function
|
||||
.call2(&global, callback.as_ref(), &wasm_bindgen::JsValue::from_f64(delay.as_secs_f64() * 1000.0 + 1.0))
|
||||
.map_err(|e| format!("scheduling GPU readback deadline: {e:?}"))?;
|
||||
// Transfer the wrapper to JS GC rather than leaking a wrapper per readback.
|
||||
let _ = callback.into_js_value();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A weak notification bridge. Shared-device hosts keep ownership of their handlers.
|
||||
pub(crate) fn failure_notifier(me: &Arc<Readbacks>) -> impl Fn(String) + Send + Sync + 'static {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let fail = {
|
||||
let me = Arc::downgrade(me);
|
||||
move |error: String| {
|
||||
if let Some(me) = me.upgrade() {
|
||||
me.retire(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fail = {
|
||||
// GPU callbacks require Send even on wasm. Capture only the index; the registry
|
||||
// (which can contain JS callbacks) stays on this worker's event-loop thread.
|
||||
let index = REGISTRIES.with(|r| {
|
||||
let mut r = r.borrow_mut();
|
||||
r.push(Arc::downgrade(me));
|
||||
r.len() - 1
|
||||
});
|
||||
move |error: String| {
|
||||
let me = REGISTRIES.with(|r| r.borrow().get(index).and_then(Weak::upgrade));
|
||||
if let Some(me) = me {
|
||||
me.retire(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
fail
|
||||
}
|
||||
|
||||
/// Replacement handlers are installed only by the successful owned-device request path.
|
||||
pub(crate) fn install(device: &wgpu::Device, me: &Arc<Readbacks>) {
|
||||
let fail = Arc::new(failure_notifier(me));
|
||||
let lost = fail.clone();
|
||||
device.set_device_lost_callback(move |reason, message| lost(format!("GPU device lost ({reason:?}): {message}")));
|
||||
device.on_uncaptured_error(Arc::new(move |e| {
|
||||
let message = format!("uncaptured GPU error: {e}");
|
||||
fail(message.clone());
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
if matches!(e, wgpu::Error::Validation { .. }) {
|
||||
panic!("{message}");
|
||||
}
|
||||
log::error!("{message}");
|
||||
}));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
thread_local! {
|
||||
static REGISTRIES: std::cell::RefCell<Vec<Weak<Readbacks>>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Validate every row before publishing tightly packed bytes.
|
||||
pub(crate) fn packed(view: &[u8], row: usize, stride: usize, height: usize) -> Result<Vec<u8>, Error> {
|
||||
if row == 0 || height == 0 || stride < row {
|
||||
return Err("invalid GPU readback layout".into());
|
||||
}
|
||||
let size = row.checked_mul(height).ok_or("GPU readback output size overflow")?;
|
||||
let end = (height - 1).checked_mul(stride).and_then(|s| s.checked_add(row)).ok_or("GPU readback mapped size overflow")?;
|
||||
if view.len() < end {
|
||||
return Err(format!("short GPU readback: {} bytes, needs {end}", view.len()));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(size).map_err(|e| format!("allocating GPU readback output: {e}"))?;
|
||||
for y in 0..height {
|
||||
let start = y.checked_mul(stride).ok_or("GPU readback row overflow")?;
|
||||
let bytes = view.get(start..start + row).ok_or("short GPU readback row")?;
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn early_rejection_reports_no_pixels_exactly_once() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = calls.clone();
|
||||
reject(
|
||||
move |pixels| {
|
||||
assert!(pixels.is_none());
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
},
|
||||
"synthetic early rejection".into(),
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(not(target_arch = "wasm32"), panic = "unwind"))]
|
||||
fn early_rejection_contains_callback_panic_and_allows_later_completion() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let first = calls.clone();
|
||||
reject(
|
||||
move |pixels| {
|
||||
assert!(pixels.is_none());
|
||||
first.fetch_add(1, Ordering::SeqCst);
|
||||
panic!("synthetic rejected callback panic");
|
||||
},
|
||||
"synthetic retired context".into(),
|
||||
);
|
||||
let second = calls.clone();
|
||||
reject(
|
||||
move |pixels| {
|
||||
assert!(pixels.is_none());
|
||||
second.fetch_add(1, Ordering::SeqCst);
|
||||
},
|
||||
"synthetic invalid layout".into(),
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(not(target_arch = "wasm32"), panic = "unwind"))]
|
||||
fn panicking_callback_does_not_strand_waiters_or_kill_deadline_worker() {
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Wake, Waker};
|
||||
struct WakeCount(AtomicUsize);
|
||||
impl Wake for WakeCount {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let me = Readbacks::new().unwrap();
|
||||
let deferred = Arc::new(crate::deferred::Deferred::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let _first = me.start(|_| panic!("synthetic public callback panic")).unwrap();
|
||||
for key in 1..=2 {
|
||||
let done = deferred.start(key, 1, 1, [0.0; 2], 1.0);
|
||||
let tx = tx.clone();
|
||||
let _ticket = me
|
||||
.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
done(None);
|
||||
tx.send(()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let wakes = Arc::new(WakeCount(AtomicUsize::new(0)));
|
||||
let waker = Waker::from(wakes.clone());
|
||||
let mut settled = std::pin::pin!(deferred.settled());
|
||||
assert!(settled.as_mut().poll(&mut Context::from_waker(&waker)).is_pending());
|
||||
me.cancel_pending("synthetic cancellation".into());
|
||||
assert_eq!(deferred.in_flight(), 0);
|
||||
assert_eq!(wakes.0.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(settled.as_mut().poll(&mut Context::from_waker(&waker)), Poll::Ready(())));
|
||||
assert!(me.0.lock().unwrap().pending.is_empty());
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
|
||||
// Drive an actual later watchdog expiry without waiting for the production 10s.
|
||||
let _first = me.start(|_| panic!("synthetic watchdog callback panic")).unwrap();
|
||||
let tx2 = tx.clone();
|
||||
let _second = me
|
||||
.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
tx2.send(()).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
{
|
||||
let mut state = me.0.lock().unwrap();
|
||||
for entry in state.pending.values_mut() {
|
||||
entry.deadline = web_time::Instant::now();
|
||||
}
|
||||
}
|
||||
rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert!(me.0.lock().unwrap().pending.is_empty());
|
||||
assert!(me.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_callback_times_out_once_and_rejects_late_success() {
|
||||
let me = Arc::new(Readbacks::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c = calls.clone();
|
||||
let ticket = me
|
||||
.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
me.expire(web_time::Instant::now() + TIMEOUT + Duration::from_secs(1));
|
||||
assert!(!ticket.active());
|
||||
ticket.complete(Ok(vec![42]));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(me.check().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_drains_all_and_new_context_is_independent() {
|
||||
let old = Arc::new(Readbacks::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut tickets = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let c = calls.clone();
|
||||
tickets.push(
|
||||
old.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
old.retire("synthetic device failure".into());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
let new = Arc::new(Readbacks::default());
|
||||
let c = calls.clone();
|
||||
let fresh = new
|
||||
.start(move |r| {
|
||||
assert_eq!(r.unwrap(), vec![7]);
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
for t in tickets {
|
||||
t.complete(Ok(vec![99]));
|
||||
}
|
||||
assert!(fresh.active());
|
||||
fresh.complete(Ok(vec![7]));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
#[test]
|
||||
fn cancelled_ticket_cannot_complete_a_new_request_on_same_context() {
|
||||
let me = Arc::new(Readbacks::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c = calls.clone();
|
||||
let old = me
|
||||
.start(move |r| {
|
||||
assert!(r.is_err());
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
me.cancel_pending("cancelled".into());
|
||||
let c = calls.clone();
|
||||
let new = me
|
||||
.start(move |r| {
|
||||
assert_eq!(r.unwrap(), vec![7]);
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
old.complete(Ok(vec![99]));
|
||||
assert!(new.active());
|
||||
new.complete(Ok(vec![7]));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn completion_and_failure_race_invokes_callback_once() {
|
||||
for _ in 0..32 {
|
||||
let me = Arc::new(Readbacks::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c = calls.clone();
|
||||
let ticket = me
|
||||
.start(move |_| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let b = barrier.clone();
|
||||
let t = std::thread::spawn(move || {
|
||||
b.wait();
|
||||
ticket.complete(Ok(vec![1]));
|
||||
});
|
||||
barrier.wait();
|
||||
me.retire("concurrent device failure".into());
|
||||
t.join().unwrap();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_layouts_fail_without_allocating() {
|
||||
assert!(Layout::new(u32::MAX, 2, 16, u64::MAX).is_err());
|
||||
assert!(Layout::new(1, 0, 4, u64::MAX).is_err());
|
||||
assert!(Layout::new(8192, 8192, 16, 128).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn completed_request_survives_later_failure_without_second_callback() {
|
||||
let me = Arc::new(Readbacks::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c = calls.clone();
|
||||
let ticket = me
|
||||
.start(move |r| {
|
||||
assert_eq!(r.unwrap(), vec![1]);
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
})
|
||||
.unwrap();
|
||||
ticket.complete(Ok(vec![1]));
|
||||
me.retire("later failure".into());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
#[test]
|
||||
fn no_partial_rows() {
|
||||
assert!(packed(&[0; 7], 4, 4, 2).is_err());
|
||||
assert_eq!(packed(&[1, 2, 0, 0, 3, 4], 2, 4, 2).unwrap(), vec![1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,9 +25,7 @@ struct Post {
|
|||
|
||||
@group(0) @binding(0) var<uniform> P: Post;
|
||||
@group(0) @binding(1) var color_in: texture_2d<f32>;
|
||||
// Camera depth as f32 bits (an `R32Uint` target: integer targets render on every backend,
|
||||
// `R32Float` doesn't on GLES without EXT_color_buffer_float).
|
||||
@group(0) @binding(2) var z_in: texture_2d<u32>;
|
||||
@group(0) @binding(2) var z_in: texture_2d<f32>;
|
||||
@group(0) @binding(3) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> depth: array<f32>;
|
||||
@group(0) @binding(5) var<storage, read_write> src: array<vec4<f32>>;
|
||||
|
|
@ -57,7 +55,7 @@ fn resolve(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
for (var i = 0u; i < k; i = i + 1u) {
|
||||
let p = vec2<i32>(i32(id.x * k + i), i32(id.y * k + j));
|
||||
a = a + textureLoad(color_in, p, 0);
|
||||
let z = bitcast<f32>(textureLoad(z_in, p, 0).x);
|
||||
let z = textureLoad(z_in, p, 0).x;
|
||||
if (z >= 0.0) {
|
||||
dz = dz + z;
|
||||
dn = dn + 1.0;
|
||||
|
|
|
|||
|
|
@ -75,28 +75,26 @@ fn srgb_to_linear(c: f32) -> f32 {
|
|||
return pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
// `a` mod `n` in [0, n) for n > 0 (floored). Never `%` on a negative operand: WGSL's `%` truncates,
|
||||
// but naga's GLSL output is plain `%`, undefined for negative operands (wrong on Mesa's GL).
|
||||
fn imod(a: i32, n: i32) -> i32 {
|
||||
if (a >= 0) {
|
||||
return a % n;
|
||||
}
|
||||
return n - 1 - (-(a + 1)) % n;
|
||||
}
|
||||
|
||||
fn wrap_i(i: i32, n: i32, mode: u32) -> i32 {
|
||||
if (mode == 1u) {
|
||||
return clamp(i, 0, n - 1);
|
||||
}
|
||||
if (mode == 2u) {
|
||||
let p = 2 * n;
|
||||
let m = imod(i, p);
|
||||
var m = i % p;
|
||||
if (m < 0) {
|
||||
m = m + p;
|
||||
}
|
||||
if (m >= n) {
|
||||
return p - 1 - m;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
return imod(i, n);
|
||||
var m = i % n;
|
||||
if (m < 0) {
|
||||
m = m + n;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
fn texel(t: vec4<u32>, x: i32, y: i32) -> vec4<f32> {
|
||||
|
|
@ -284,8 +282,7 @@ fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_
|
|||
|
||||
struct FOut {
|
||||
@location(0) color: vec4<f32>,
|
||||
// Camera depth's f32 bits (an `R32Uint` target, see `adv3d.rs`).
|
||||
@location(1) depth: u32,
|
||||
@location(1) depth: f32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
|
|
@ -329,7 +326,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
|||
emissive = emissive * sample_tex(tex_em, i.uv).rgb;
|
||||
}
|
||||
var out: FOut;
|
||||
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
|
||||
out.depth = dot(g.view_z.xyz, i.pos) + g.view_z.w;
|
||||
if (unlit || !accepts_lights || g.env2.y < 0.5) {
|
||||
let c = rgb + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ fn attenuation(l: u32, p: vec3<f32>) -> f32 {
|
|||
}
|
||||
if (li.ints.x == 3u) {
|
||||
let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0);
|
||||
let a = acos_p(cos_a);
|
||||
let a = acos(cos_a);
|
||||
let outer = li.cone.x;
|
||||
let inner = outer * (1.0 - li.cone.y);
|
||||
if (a <= inner) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue