Compare commits
84 Commits
ci-packagi
...
fix/106-gp
| Author | SHA1 | Date |
|---|---|---|
|
|
e64076e432 | |
|
|
0fac647928 | |
|
|
93856382e7 | |
|
|
4a401a4785 | |
|
|
27b0eec216 | |
|
|
1a9288f83c | |
|
|
8a415e03ca | |
|
|
ea25e402b1 | |
|
|
fa25545d8b | |
|
|
c750b299cf | |
|
|
588d269d29 | |
|
|
aa31ffc2fb | |
|
|
0ffa2470b3 | |
|
|
dda9961889 | |
|
|
a46ebf4dc7 | |
|
|
7002c92632 | |
|
|
4b9e701223 | |
|
|
d4e310b252 | |
|
|
0e27c845f7 | |
|
|
7e1c2d0329 | |
|
|
370a7a5131 | |
|
|
dc96725725 | |
|
|
633fbcfaeb | |
|
|
dfd8aac431 | |
|
|
98872a4690 | |
|
|
e6c817bb3f | |
|
|
e1e0a78be5 | |
|
|
722d7d673c | |
|
|
17b17bcce3 | |
|
|
25233abbcb | |
|
|
39e6907b5f | |
|
|
80c246d843 | |
|
|
9624024dc1 | |
|
|
b46be83ab4 | |
|
|
86ae2fa218 | |
|
|
f0578a5940 | |
|
|
42401e7672 | |
|
|
c8e9efb962 | |
|
|
877afeb25a | |
|
|
f25b75486b | |
|
|
857340036b | |
|
|
25f353fb2c | |
|
|
dd56ae5c00 | |
|
|
df12664b8e | |
|
|
fdfbca5faf | |
|
|
b424c321dc | |
|
|
72e60dc6fa | |
|
|
e6d4a64775 | |
|
|
83cd5d0f45 | |
|
|
efcbc2e412 | |
|
|
bb7b5f51de | |
|
|
1b8780f6da | |
|
|
75640044b8 | |
|
|
77c056a8db | |
|
|
0dcbe325ab | |
|
|
782a67f228 | |
|
|
4373c24962 | |
|
|
0286a49f0a | |
|
|
41f5ee3aec | |
|
|
a0c1b60d61 | |
|
|
e5c58600fd | |
|
|
b70c808d60 | |
|
|
7f3caeea70 | |
|
|
3767332efd | |
|
|
6eedf8e2c5 | |
|
|
24fcbdf3d2 | |
|
|
044fc32a9c | |
|
|
b9ffd889bc | |
|
|
e43bf66775 | |
|
|
e2f027f374 | |
|
|
4624c00308 | |
|
|
174ab2b6af | |
|
|
7579de21c6 | |
|
|
aa4656b543 | |
|
|
11d4885764 | |
|
|
5afc25d0d8 | |
|
|
9607be471b | |
|
|
f013bdd8d7 | |
|
|
1ac55c33a3 | |
|
|
97390f15fb | |
|
|
6a5abad753 | |
|
|
26c714db7f | |
|
|
4f7723a7b6 | |
|
|
0f1cad8bbd |
|
|
@ -0,0 +1,48 @@
|
|||
# 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,6 +47,10 @@ 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,6 +112,9 @@ 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 }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
# 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: PrintCraft 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'
|
||||
|
|
@ -105,8 +105,15 @@ 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,6 +70,34 @@ 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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
|||
`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. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask.
|
||||
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.
|
||||
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.** No Adobe assets, every asset licensed + attributed (`cargo xtask assets`).
|
||||
|
|
|
|||
|
|
@ -1134,7 +1134,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1155,7 +1155,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-automation"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-host",
|
||||
|
|
@ -1167,14 +1167,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-av1enc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"filmcraft-av1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"effectcraft-automation",
|
||||
|
|
@ -1190,7 +1190,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1198,13 +1198,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-effects"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-project",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-track",
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -1214,7 +1215,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1229,6 +1230,7 @@ dependencies = [
|
|||
"effectcraft-psd",
|
||||
"effectcraft-raster",
|
||||
"effectcraft-render",
|
||||
"effectcraft-segment",
|
||||
"effectcraft-svg",
|
||||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
|
|
@ -1246,7 +1248,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-export"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-av1enc",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1275,7 +1277,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-expr"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1292,7 +1294,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1300,7 +1302,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-gpu"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effectcraft-color",
|
||||
|
|
@ -1319,14 +1321,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-hevcenc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"filmcraft-hevc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-host"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1348,7 +1350,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-interchange"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1367,7 +1369,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-keyframe"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1379,7 +1381,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-lottie"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1396,7 +1398,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-media"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-model",
|
||||
"effectcraft-pdf",
|
||||
|
|
@ -1421,7 +1423,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-model"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"image",
|
||||
|
|
@ -1432,14 +1434,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-opusenc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"filmcraft-opus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-path"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1451,7 +1453,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-pdf"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-svg",
|
||||
|
|
@ -1465,7 +1467,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-plugin"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-effects",
|
||||
"effectcraft-keyframe",
|
||||
|
|
@ -1477,7 +1479,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-project"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1490,14 +1492,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-psd"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-raster"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-geom",
|
||||
|
|
@ -1506,7 +1508,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-effects",
|
||||
|
|
@ -1528,7 +1530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-script"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1541,9 +1543,18 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-segment"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"ndarray",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-svg"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-geom",
|
||||
"effectcraft-path",
|
||||
|
|
@ -1555,7 +1566,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-keyframe",
|
||||
"harfrust",
|
||||
|
|
@ -1567,7 +1578,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-time"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -1575,9 +1586,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-track"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-raster",
|
||||
"effectcraft-segment",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1586,7 +1598,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
|
|
@ -1614,11 +1626,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "effectcraft-vp9enc"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
|
||||
[[package]]
|
||||
name = "effectcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"effectcraft-engine",
|
||||
"effectcraft-export",
|
||||
|
|
@ -1732,6 +1744,7 @@ dependencies = [
|
|||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
|
|
@ -2313,6 +2326,15 @@ 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"
|
||||
|
|
@ -2719,6 +2741,27 @@ 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"
|
||||
|
|
@ -3050,6 +3093,16 @@ 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"
|
||||
|
|
@ -3167,6 +3220,21 @@ 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"
|
||||
|
|
@ -4169,6 +4237,12 @@ 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"
|
||||
|
|
@ -5187,6 +5261,18 @@ 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"
|
||||
|
|
@ -5575,6 +5661,22 @@ 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"
|
||||
|
|
@ -6318,7 +6420,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
|
@ -22,6 +22,7 @@ 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" }
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -97,8 +97,8 @@ Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you
|
|||
- **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, the Character and Paragraph panels, and
|
||||
text animators with range selectors.
|
||||
- **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.
|
||||
- **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
|
||||
|
|
@ -179,7 +179,14 @@ See [docs/agents.md](docs/agents.md) and [docs/control-protocol.md](docs/control
|
|||
|
||||
## Get started
|
||||
|
||||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
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.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/effectcraft
|
||||
|
|
@ -195,13 +202,25 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
|||
|
||||
## Where it stands
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## How it's made
|
||||
|
||||
|
|
|
|||
48
ROADMAP.md
48
ROADMAP.md
|
|
@ -1,5 +1,7 @@
|
|||
# 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.
|
||||
|
|
@ -10,7 +12,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 | 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) |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
|
@ -19,20 +21,46 @@ 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 (Roto Brush and face tracking use classical models; learned models are still to come) |
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026):
|
||||
There are two honest answers, and they are far apart. The full assessment, with the evidence, is
|
||||
in [docs/gaps.md](docs/gaps.md).
|
||||
|
||||
- **≈ 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)).
|
||||
- **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).
|
||||
- **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 |
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -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!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
|
||||
assert!(s.file(&p).is_some());
|
||||
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());
|
||||
// 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,7 +14,8 @@ effectcraft-engine = { workspace = true }
|
|||
effectcraft-host = { workspace = true }
|
||||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
//! 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.chunks_exact(2)) {
|
||||
for (f, s) in out.chunks_mut(ch.max(1)).zip(stereo.as_chunks::<2>().0.iter()) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@
|
|||
//!
|
||||
//! `--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")]
|
||||
|
|
@ -17,11 +20,15 @@ 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] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
|
||||
let png: &[u8] = ICON_PNG;
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +54,11 @@ fn main() -> eframe::Result {
|
|||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// The font menus list the installed fonts: read their names while the window opens.
|
||||
effectcraft_engine::text::fonts::scan_system_in_background();
|
||||
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
|
||||
#[cfg(target_os = "linux")]
|
||||
appimage::integrate_from_env(ICON_PNG);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("EffectCraft")
|
||||
|
|
@ -57,7 +69,7 @@ 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("ai.storyteller.effectcraft"),
|
||||
.with_app_id(appimage::APP_ID),
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -72,7 +84,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) = config_dir() {
|
||||
if let Some(dir) = effectcraft_host::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() {
|
||||
|
|
@ -163,24 +175,6 @@ 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> {
|
||||
|
|
|
|||
|
|
@ -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.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();
|
||||
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();
|
||||
enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8)
|
||||
} else {
|
||||
enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8)
|
||||
|
|
|
|||
|
|
@ -393,3 +393,31 @@ 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:?}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
let one = call_json(&mut s, "list_fonts", json!({"query": "jetbrains"}));
|
||||
assert_eq!(one["count"], 1, "{one}");
|
||||
assert_eq!(one["families"][0]["family"], "JetBrains Mono");
|
||||
assert!(one["families"][0]["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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@ 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.
|
||||
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
|
||||
|
|
@ -607,6 +611,21 @@ 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.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect() };
|
||||
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() };
|
||||
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)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
let u: Vec<u16> = t.get(off..off + len)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
|
||||
}
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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.chunks_exact_mut(2).for_each(|f| f.swap(0, 1));
|
||||
buf.as_chunks_mut::<2>().0.iter_mut().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.chunks_exact_mut(2) {
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
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.chunks_exact_mut(2) {
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
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.chunks_exact_mut(2).enumerate() {
|
||||
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().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.chunks_exact_mut(2) {
|
||||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||||
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,7 +173,8 @@ mod tests {
|
|||
std::fs::write(path, &want).unwrap();
|
||||
return;
|
||||
}
|
||||
let have = std::fs::read_to_string(path).unwrap_or_default();
|
||||
// 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");
|
||||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@
|
|||
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
|
||||
//! without strokes the effect passes its input through.
|
||||
//!
|
||||
//! *Version* is stored but all versions use the same (classic, graph-cut) engine; *Quality ▸
|
||||
//! Best* segments at a higher working resolution with more iterations.
|
||||
//! *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.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
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::*;
|
||||
|
|
@ -173,8 +177,29 @@ 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 and the segmentation settings.
|
||||
/// size, the frame rate, the segmentation settings and the model.
|
||||
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());
|
||||
|
|
@ -189,6 +214,7 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -275,13 +301,15 @@ 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) }
|
||||
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params) }
|
||||
}
|
||||
|
||||
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
|
||||
|
|
@ -331,7 +359,7 @@ impl Chain {
|
|||
let img = frame(g)?;
|
||||
let strokes = self.data.strokes_at(g);
|
||||
let seg = match (&cur, g == base) {
|
||||
(_, true) | (None, _) => rb::segment(&img, &strokes, None, &self.opts, self.refine_radius),
|
||||
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
|
||||
(Some(p), false) => {
|
||||
let src = self.data.source_of(g)?;
|
||||
let pimg = match prev_img.take() {
|
||||
|
|
@ -341,7 +369,7 @@ impl Chain {
|
|||
if pimg.width != img.width || pimg.height != img.height {
|
||||
return None;
|
||||
}
|
||||
rb::propagate(&pimg, p, &img, &strokes, &self.opts, self.refine_radius)
|
||||
rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, self.model.as_deref())
|
||||
}
|
||||
};
|
||||
let seg = Arc::new(seg);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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 }
|
||||
|
|
|
|||
|
|
@ -46,6 +46,14 @@ 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),
|
||||
|
|
@ -64,6 +72,14 @@ 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",
|
||||
|
|
|
|||
|
|
@ -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.chunks_exact(2) {
|
||||
for c in buf.as_chunks::<2>().0 {
|
||||
l += c[0].abs() as f64;
|
||||
r += c[1].abs() as f64;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, LayerId, LayerSource, Marker};
|
||||
use effectcraft_project::{Comp, CuePoint, ItemId, Layer, LayerId, LayerSource, Marker, Project};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -85,6 +85,56 @@ 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))
|
||||
}
|
||||
|
|
@ -299,6 +349,12 @@ 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,6 +187,7 @@ 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);
|
||||
|
|
@ -195,6 +196,8 @@ 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;
|
||||
mod markers;
|
||||
pub mod markers;
|
||||
mod mask;
|
||||
pub mod mask_interp;
|
||||
pub(crate) mod model3d;
|
||||
|
|
@ -133,6 +133,7 @@ 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());
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// `item` (id or name) → id.
|
||||
fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result<ItemId> {
|
||||
|
|
@ -140,17 +140,40 @@ 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 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 doomed = doomed_items(&s.project, &ids);
|
||||
let n = doomed.len();
|
||||
s.edit("Delete Items", None, |proj, st| {
|
||||
for id in &doomed {
|
||||
|
|
@ -242,6 +265,7 @@ 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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -498,6 +498,33 @@ 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(())
|
||||
}
|
||||
|
|
@ -527,6 +554,15 @@ 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,6 +26,7 @@ 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;
|
||||
|
|
@ -63,6 +64,7 @@ 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;
|
||||
|
|
@ -347,6 +349,10 @@ 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>,
|
||||
|
|
@ -463,6 +469,8 @@ 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![],
|
||||
|
|
@ -1010,6 +1018,17 @@ 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()
|
||||
|
|
@ -1079,6 +1098,8 @@ 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;
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ 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) {
|
||||
|
|
@ -275,7 +277,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(&trk::Frame { img: &first.0, offset: first.1 }, b) else {
|
||||
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 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.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect())
|
||||
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())
|
||||
}
|
||||
|
||||
/// Store a summary (atomically: written beside, then renamed).
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,463 @@
|
|||
//! 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,6 +519,7 @@ 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,6 +44,8 @@ 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).
|
||||
|
|
@ -209,6 +211,24 @@ 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,
|
||||
|
|
@ -272,6 +292,8 @@ 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.
|
||||
|
|
@ -312,6 +334,8 @@ 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![],
|
||||
|
|
@ -393,6 +417,9 @@ 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);
|
||||
|
|
@ -431,6 +458,9 @@ 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}`"))?;
|
||||
|
|
@ -709,6 +739,8 @@ pub fn section_key(page: &str) -> Option<&'static str> {
|
|||
"video" => "video",
|
||||
"3d" | "threeD" => "threeD",
|
||||
"scripting" => "scripting",
|
||||
"roto" => "roto",
|
||||
"face" => "face",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -735,6 +767,8 @@ 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)
|
||||
|
|
@ -822,6 +856,9 @@ 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.
|
||||
|
|
@ -848,6 +885,7 @@ 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),
|
||||
|
|
@ -1096,6 +1134,28 @@ 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),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1148,6 +1208,10 @@ 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})
|
||||
|
|
|
|||
|
|
@ -636,6 +636,7 @@ 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!(r["outputPath"], "/tmp/rq/out.mov");
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
// 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,11 +77,14 @@ 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(), vec!["/tmp/rq/out.mov".to_string(), "/tmp/rq/fail.gif".to_string()]);
|
||||
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()]
|
||||
);
|
||||
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!(q[0].last_output.as_deref(), Some("/tmp/rq/out.mov"));
|
||||
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
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,6 +38,8 @@ 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,6 +101,16 @@ 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")]
|
||||
|
|
@ -112,6 +122,10 @@ 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(
|
||||
|
|
@ -136,6 +150,10 @@ 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();
|
||||
|
|
@ -156,6 +174,10 @@ 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();
|
||||
|
|
@ -172,6 +194,18 @@ 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,15 +716,14 @@ 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 = match s.config.as_ref().and_then(|c| c.dir()) {
|
||||
Some(d) => d.join(FOOTAGE_DIR),
|
||||
None => PathBuf::from(format!("/{FOOTAGE_DIR}")),
|
||||
};
|
||||
let base = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(FOOTAGE_DIR));
|
||||
// A fresh folder per created project.
|
||||
let mut k = 1;
|
||||
loop {
|
||||
let d = base.join(if k == 1 { safe.clone() } else { format!("{safe} {k}") });
|
||||
let ds = d.to_string_lossy().into_owned();
|
||||
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}"));
|
||||
if !s.services.exists(&ds) || k > 999 {
|
||||
return ds;
|
||||
}
|
||||
|
|
@ -955,7 +954,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.chunks_exact(3).map(|c| [c[0] / 16, c[1] / 16, c[2] / 16]).collect();
|
||||
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();
|
||||
assert!(distinct.len() > 3, "{}: thumbnail looks blank", t.id);
|
||||
}
|
||||
let sq = l.iter().find(|t| t.id == "social-square").unwrap();
|
||||
|
|
@ -1121,7 +1120,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!(get("Still").1, dir.join("still.png").to_string_lossy());
|
||||
assert_eq!(std::path::Path::new(&get("Still").1), dir.join("still.png"));
|
||||
assert_eq!(std::fs::read(dir.join("still.png")).unwrap(), vec![1u8; 1000]);
|
||||
let shot = get("Shot");
|
||||
assert_eq!(shot.2.len(), 3);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,27 @@ fn demo_layer_cache_is_transparent() {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ 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();
|
||||
|
|
@ -116,7 +117,22 @@ 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,3 +104,37 @@ 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!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png");
|
||||
assert_eq!(PathBuf::from(p.output_name("/out/Comp 1.png", 16)), PathBuf::from("/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)| p.as_str()).collect::<Vec<_>>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]);
|
||||
assert_eq!(segs.iter().map(|(_, p)| PathBuf::from(p)).collect::<Vec<_>>(), [PathBuf::from("/o/Main_001.mp4"), PathBuf::from("/o/Main_002.mp4")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -147,3 +147,37 @@ 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,6 +255,7 @@ 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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
//! 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);
|
||||
}
|
||||
|
|
@ -10,6 +10,21 @@ 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 {
|
||||
|
|
@ -74,6 +89,7 @@ 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!(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");
|
||||
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"));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
|
|
@ -486,3 +486,25 @@ 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,9 +284,20 @@ 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(), vec!["H264:/tmp/om/main.mp4".to_string(), "ProRes:/tmp/om/second.mov".to_string()]);
|
||||
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!(s.project.render_queue[0].status.label(), "Done");
|
||||
assert_eq!(s.project.render_queue[0].last_output.as_deref(), Some("/tmp/om/main.mp4"));
|
||||
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/om/main.mp4").unwrap());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit");
|
||||
}
|
||||
|
|
@ -307,7 +318,9 @@ 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 f.path == "/tmp/pre/inner.mov"));
|
||||
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())
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
|
||||
}
|
||||
|
|
@ -323,13 +336,15 @@ 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 f.path == "/tmp/post/a.mov")));
|
||||
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())
|
||||
));
|
||||
// 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!(px.footage.path, "/tmp/post/b.mov");
|
||||
assert_eq!(std::path::Path::new(&px.footage.path), std::path::absolute("/tmp/post/b.mov").unwrap());
|
||||
// None: nothing happens.
|
||||
let n = s.project.items.len();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
|
||||
|
|
@ -413,7 +428,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!(a["logPath"], "/tmp/n/a_RenderLog.txt");
|
||||
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::absolute("/tmp/n/a_RenderLog.txt").unwrap());
|
||||
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,3 +227,27 @@ 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 _;
|
||||
for face in effectcraft_text::fonts::DirectorySource::system().faces() {
|
||||
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");
|
||||
assert_eq!(r["families"][0]["styles"], json!(["Regular"]));
|
||||
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"), vec!["Regular".to_string()]);
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.chunks_exact(4)) {
|
||||
for (d, s) in a.iter_mut().zip(rgba.as_chunks::<4>().0.iter()) {
|
||||
*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.chunks_exact(2).map(|p| (p[0] + p[1]) * 0.5).collect() } else { st }
|
||||
if cx.output.audio_channels == 1 { st.as_chunks::<2>().0.iter().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.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
let rgb: Vec<u8> = px.as_chunks::<4>().0.iter().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.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),
|
||||
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),
|
||||
};
|
||||
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.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
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();
|
||||
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!(path, "/l/b_RenderLog.txt");
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/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!(path, "/l/d_RenderLog.txt");
|
||||
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/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 !path.starts_with("/primary/") {
|
||||
if !std::path::Path::new(path).starts_with("/primary") {
|
||||
return true;
|
||||
}
|
||||
let mut u = self.used.lock().unwrap();
|
||||
|
|
@ -278,7 +278,14 @@ 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, _)| p.starts_with("/overflow/seq_") && p.ends_with(".png")).count(), 10);
|
||||
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
|
||||
);
|
||||
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);
|
||||
|
|
@ -288,7 +295,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, _)| p.starts_with("/primary/")));
|
||||
assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(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..].chunks_exact(4).skip(14_400).take(24_000).map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32767.0);
|
||||
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);
|
||||
rms(left)
|
||||
};
|
||||
assert!((l - 0.3536).abs() < 0.01, "{name}: left RMS {l}");
|
||||
|
|
|
|||
|
|
@ -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 (`R32Float`) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Uint` holding f32 bits) 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
|
||||
|
|
@ -22,20 +22,44 @@ use crate::context::{Enc, GpuContext, GpuImage};
|
|||
/// Pipelines (built on first use).
|
||||
pub(crate) struct Pipes {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
opaque: wgpu::RenderPipeline,
|
||||
transparent: wgpu::RenderPipeline,
|
||||
/// (opaque, transparent); `None` when the adapter can't rasterise ([`raster_unsupported`]):
|
||||
/// scenes then render on the CPU.
|
||||
raster: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
post: Post,
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device))
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device, g.adv3d_raster))
|
||||
}
|
||||
|
||||
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Float;
|
||||
/// 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: 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> {
|
||||
use wgpu::TextureFormatFeatureFlags as F;
|
||||
let needs = [(COLOR, F::BLENDABLE), (DEPTH_OUT, F::empty()), (DEPTH, F::empty())];
|
||||
for (format, flags) in needs {
|
||||
let f = adapter.get_texture_format_features(format);
|
||||
if !f.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) || !f.flags.contains(flags) {
|
||||
return Some(format!("{format:?} is not a {}render target", if flags.is_empty() { "" } else { "blendable " }));
|
||||
}
|
||||
}
|
||||
// The transparent pipeline blends colour and masks the depth target off.
|
||||
if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND) {
|
||||
return Some("no independent blending".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
|
|
@ -46,7 +70,7 @@ fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
|||
}
|
||||
|
||||
impl Pipes {
|
||||
fn new(device: &wgpu::Device) -> Pipes {
|
||||
fn new(device: &wgpu::Device, raster: bool) -> Pipes {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/advanced3d.wgsl").into()),
|
||||
|
|
@ -110,7 +134,7 @@ impl Pipes {
|
|||
cache: None,
|
||||
})
|
||||
};
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl, post: Post::new(device) }
|
||||
Pipes { raster: raster.then(|| (make(false), make(true))), bgl, post: Post::new(device) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,9 +246,9 @@ fn half_to_f32(h: u16) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Float`
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Uint`
|
||||
/// with −1 where nothing was drawn) at the scene's raster size. `None` when the device can't
|
||||
/// (size or buffer limits).
|
||||
/// (render targets, size or buffer limits).
|
||||
fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let g = e.g;
|
||||
if !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
|
|
@ -236,6 +260,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
return None;
|
||||
}
|
||||
let p = pipes(g);
|
||||
let Some((opaque, transparent)) = &p.raster else { return None };
|
||||
let dev = &g.device;
|
||||
let vb: Vec<u8> = s
|
||||
.vertices
|
||||
|
|
@ -294,7 +319,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: -1.0, g: 0.0, b: 0.0, a: 0.0 }), store: wgpu::StoreOp::Store },
|
||||
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 },
|
||||
}),
|
||||
],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
|
|
@ -311,11 +336,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
|||
pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
|
||||
let n = s.indices.len() as u32;
|
||||
if s.opaque_count > 0 {
|
||||
pass.set_pipeline(&p.opaque);
|
||||
pass.set_pipeline(opaque);
|
||||
pass.draw_indexed(0..s.opaque_count, 0, 0..1);
|
||||
}
|
||||
if n > s.opaque_count {
|
||||
pass.set_pipeline(&p.transparent);
|
||||
pass.set_pipeline(transparent);
|
||||
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
|
||||
}
|
||||
}
|
||||
|
|
@ -362,9 +387,11 @@ 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.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 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 depth = zb
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z < 0.0 { f32::INFINITY } else { z }
|
||||
|
|
@ -424,14 +451,10 @@ impl Post {
|
|||
label: Some("effectcraft advanced 3d post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/adv3d.wgsl").into()),
|
||||
});
|
||||
let tex = |binding| wgpu::BindGroupLayoutEntry {
|
||||
let tex = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
ty: wgpu::BindingType::Texture { sample_type, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
|
||||
count: None,
|
||||
};
|
||||
let buf = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
|
|
@ -447,8 +470,8 @@ impl Post {
|
|||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex(1),
|
||||
tex(2),
|
||||
tex(1, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
tex(2, wgpu::TextureSampleType::Uint),
|
||||
buf(3, false),
|
||||
buf(4, false),
|
||||
buf(5, false),
|
||||
|
|
@ -744,7 +767,9 @@ 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
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.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 }
|
||||
|
|
@ -791,7 +816,9 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
|
|||
dst.copy_from_slice(&a.bytes);
|
||||
let depth = b
|
||||
.bytes
|
||||
.chunks_exact(4)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.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 }
|
||||
|
|
|
|||
|
|
@ -259,6 +259,9 @@ 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,
|
||||
|
|
@ -288,21 +291,54 @@ fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGro
|
|||
}
|
||||
}
|
||||
|
||||
/// Log the device's uncaptured errors instead of wgpu's default panic.
|
||||
fn log_uncaptured_errors(device: &wgpu::Device) {
|
||||
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}")
|
||||
}));
|
||||
}
|
||||
|
||||
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).
|
||||
/// textures) or the device was created with limits below what its pipelines bind.
|
||||
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 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"),
|
||||
|
|
@ -447,6 +483,12 @@ impl GpuContext {
|
|||
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;
|
||||
// From here on errors are logged, not fatal: wgpu's default handler panics, and egui-wgpu
|
||||
// installs none on the device the desktop app shares. Running out of video memory on a
|
||||
// frame thread must not crash the app (#106); frames catch it with
|
||||
// [`crate::Gpu::within_memory`]. (Errors while the pipelines are built above still panic
|
||||
// on that device, which the app catches to fall back to the CPU.)
|
||||
log_uncaptured_errors(&device);
|
||||
Ok(GpuContext {
|
||||
device,
|
||||
queue,
|
||||
|
|
@ -466,6 +508,7 @@ 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,
|
||||
|
|
@ -486,6 +529,12 @@ 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;
|
||||
|
|
@ -505,16 +554,7 @@ impl GpuContext {
|
|||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
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}")
|
||||
}));
|
||||
log_uncaptured_errors(&device);
|
||||
GpuContext::new(&adapter, device, queue)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,27 @@ impl Gpu {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run GPU work `f` on this thread, catching the device running out of video memory
|
||||
/// meanwhile (#106). `Err` (why) means the result is incomplete: a texture that failed to
|
||||
/// allocate holds nothing. The uploaded layers and pooled textures are then freed, so the
|
||||
/// next render has room. The browser reports it to the device's error handler instead.
|
||||
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
#[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 => Ok(out),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Ok(f())
|
||||
}
|
||||
|
||||
/// Wait for all submitted GPU work (benchmarks).
|
||||
pub fn wait(&self) {
|
||||
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
|
||||
|
|
|
|||
|
|
@ -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), 0.0];
|
||||
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[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), 0.0, 0.0];
|
||||
p.f[0] = [opacity, levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v), 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,6 +174,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -233,8 +233,10 @@ 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.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
|
||||
f.chunks_exact(12)
|
||||
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()
|
||||
.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 })
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ struct Post {
|
|||
|
||||
@group(0) @binding(0) var<uniform> P: Post;
|
||||
@group(0) @binding(1) var color_in: texture_2d<f32>;
|
||||
@group(0) @binding(2) var z_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(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>>;
|
||||
|
|
@ -55,7 +57,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 = textureLoad(z_in, p, 0).x;
|
||||
let z = bitcast<f32>(textureLoad(z_in, p, 0).x);
|
||||
if (z >= 0.0) {
|
||||
dz = dz + z;
|
||||
dn = dn + 1.0;
|
||||
|
|
|
|||
|
|
@ -75,26 +75,28 @@ 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;
|
||||
var m = i % p;
|
||||
if (m < 0) {
|
||||
m = m + p;
|
||||
}
|
||||
let m = imod(i, p);
|
||||
if (m >= n) {
|
||||
return p - 1 - m;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
var m = i % n;
|
||||
if (m < 0) {
|
||||
m = m + n;
|
||||
}
|
||||
return m;
|
||||
return imod(i, n);
|
||||
}
|
||||
|
||||
fn texel(t: vec4<u32>, x: i32, y: i32) -> vec4<f32> {
|
||||
|
|
@ -282,7 +284,8 @@ fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_
|
|||
|
||||
struct FOut {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: f32,
|
||||
// Camera depth's f32 bits (an `R32Uint` target, see `adv3d.rs`).
|
||||
@location(1) depth: u32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
|
|
@ -326,7 +329,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 = dot(g.view_z.xyz, i.pos) + g.view_z.w;
|
||||
out.depth = bitcast<u32>(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(cos_a);
|
||||
let a = acos_p(cos_a);
|
||||
let outer = li.cone.x;
|
||||
let inner = outer * (1.0 - li.cone.y);
|
||||
if (a <= inner) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,40 @@ struct Params {
|
|||
@group(0) @binding(2) var aux: texture_2d<f32>;
|
||||
@group(0) @binding(4) var<storage, read> data: array<f32>;
|
||||
|
||||
// `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;
|
||||
}
|
||||
|
||||
// asin / acos to f32 precision on every backend (Cephes' asinf polynomial). WGSL leaves their
|
||||
// precision to the implementation, and Mesa's GL drivers use a coarse approximation (errors near
|
||||
// 1e-4 rad), which shows in every effect that maps through a sphere or a lens.
|
||||
fn asin_p(x: f32) -> f32 {
|
||||
let a = abs(x);
|
||||
let big = a > 0.5;
|
||||
let z = select(a * a, 0.5 * (1.0 - a), big);
|
||||
let s = select(a, sqrt(z), big);
|
||||
var p = ((((4.2163199048e-2 * z + 2.4181311049e-2) * z + 4.5470025998e-2) * z + 7.4953002686e-2) * z + 1.6666752422e-1) * z * s + s;
|
||||
if (big) {
|
||||
p = 1.5707963267948966 - 2.0 * p;
|
||||
}
|
||||
return select(p, -p, x < 0.0);
|
||||
}
|
||||
|
||||
fn acos_p(x: f32) -> f32 {
|
||||
if (x > 0.5) {
|
||||
return 2.0 * asin_p(sqrt(0.5 * (1.0 - x)));
|
||||
}
|
||||
if (x < -0.5) {
|
||||
return 3.141592653589793 - 2.0 * asin_p(sqrt(0.5 * (1.0 + x)));
|
||||
}
|
||||
return 1.5707963267948966 - asin_p(x);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sampling (raster::Image)
|
||||
|
||||
fn tex_get(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ fn dst_cclens(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
}
|
||||
var s = q;
|
||||
if (d >= 1e-9) {
|
||||
let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
|
||||
let g = powz(asin_p(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
|
||||
let k = r * g / d;
|
||||
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -653,7 +653,7 @@ fn fg2_threads(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let fv = v / th - f32(cj);
|
||||
let in_h = abs(fv - 0.5) < coverage * 0.5;
|
||||
let in_v = abs(fu - 0.5) < coverage * 0.5;
|
||||
let h_top = ((ci + cj) % (2 * overlaps) + 2 * overlaps) % (2 * overlaps) < overlaps;
|
||||
let h_top = imod(ci + cj, 2 * overlaps) < overlaps;
|
||||
var horizontal: bool;
|
||||
if (in_h && in_v) {
|
||||
horizontal = h_top;
|
||||
|
|
|
|||
|
|
@ -349,8 +349,8 @@ fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3<f32> {
|
|||
var tx = gx;
|
||||
var ty = gy;
|
||||
if (tile) {
|
||||
tx = ((gx % nx) + nx) % nx;
|
||||
ty = ((gy % ny) + ny) % ny;
|
||||
tx = imod(gx, nx);
|
||||
ty = imod(gy, ny);
|
||||
}
|
||||
let hx = bitcast<u32>(tx);
|
||||
let hy = bitcast<u32>(ty);
|
||||
|
|
@ -428,7 +428,7 @@ fn gen_axis(q: f32, size: f32, f: f32) -> f32 {
|
|||
let fr = u - fl;
|
||||
let d = min(fr, 1.0 - fr) * size;
|
||||
let n = i32(fl);
|
||||
let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0);
|
||||
let s = select(-1.0, 1.0, imod(n, 2) == 0);
|
||||
return s * min(d / (f * 0.5), 1.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@
|
|||
// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed
|
||||
// `fxk_` (the family files share one module).
|
||||
|
||||
// Correct the approximate quotient before bitwise/threshold operations.
|
||||
fn fxk_div3(a: vec3<f32>, b: f32) -> vec3<f32> {
|
||||
let q = a / b;
|
||||
let r = fma(-q, vec3<f32>(b), a);
|
||||
return fma(r, vec3<f32>(1.0 / b), q);
|
||||
}
|
||||
|
||||
// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6.
|
||||
fn fxk_straight(px: vec4<f32>) -> vec3<f32> {
|
||||
if (px.w > 1e-6) {
|
||||
return px.xyz / px.w;
|
||||
return fxk_div3(px.xyz, px.w);
|
||||
}
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
|
|
@ -457,7 +464,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
let s = px.xyz / px.w;
|
||||
let s = fxk_div3(px.xyz, px.w);
|
||||
var r = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < 3u; i++) {
|
||||
let v = s[i];
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ fn fxn_pixel(gid: vec3<u32>) -> vec2<i32> {
|
|||
}
|
||||
|
||||
// a / b rounded like the CPU's IEEE division (GPU division may be approximate): one
|
||||
// residual correction with fma. Used where a threshold follows the quotient.
|
||||
// residual correction with fma, followed by a second refinement for half-step cases.
|
||||
// Used where a strict threshold follows the quotient.
|
||||
fn fxn_div4(a: vec4<f32>, b: vec4<f32>) -> vec4<f32> {
|
||||
let q = a / b;
|
||||
let r = fma(-q, b, a);
|
||||
return fma(r, 1.0 / b, q);
|
||||
let corrected = fma(r, 1.0 / b, q);
|
||||
let residual = fma(-corrected, b, a);
|
||||
return fma(residual, 1.0 / b, corrected);
|
||||
}
|
||||
|
||||
// util::unpremul: (straight colour, alpha).
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let hy = r * 1.5 * rz;
|
||||
var lu = u - hx;
|
||||
var lv = v - hy;
|
||||
let odd = ((i32(rx) + i32(rz)) % 2 + 2) % 2 == 1;
|
||||
let odd = imod(i32(rx) + i32(rz), 2) == 1;
|
||||
if ((mode == 1u || mode == 2u) && odd) {
|
||||
lu = -lu;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fn fxs_qdiv(x: vec3<f32>, a: f32) -> vec3<f32> {
|
|||
}
|
||||
|
||||
fn fxs_rem2(i: i32) -> i32 {
|
||||
return ((i % 2) + 2) % 2;
|
||||
return imod(i, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-pixel colour effects
|
||||
|
|
@ -257,7 +257,7 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let d = sqrt(dx * dx + dy * dy);
|
||||
if (d < r && d != 0.0) {
|
||||
let nd = d / r;
|
||||
let k = (asin(nd) / FXS_HALF_PI) / nd;
|
||||
let k = (asin_p(nd) / FXS_HALF_PI) / nd;
|
||||
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
|
||||
}
|
||||
}
|
||||
|
|
@ -453,10 +453,15 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
u = bu + (u - bu) * persp;
|
||||
v = bv + (v - bv) * persp;
|
||||
}
|
||||
if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) {
|
||||
// The CPU's 1e-9 edge allowance disappears in f32 at 1.0. Allow eight
|
||||
// f32 ULPs for the inverse solve so exact quad-edge pixels remain covered.
|
||||
let edge = 0.00000095367431640625;
|
||||
if (u < -edge || u > 1.0 + edge || v < -edge || v > 1.0 + edge) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
u = clamp(u, 0.0, 1.0);
|
||||
v = clamp(v, 0.0, 1.0);
|
||||
let rc = P.f[5];
|
||||
let lx = rc.x + u * rc.z;
|
||||
let ly = rc.y + v * rc.w;
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
return;
|
||||
}
|
||||
let tau = 6.283185307179586;
|
||||
let th = asin(s);
|
||||
let th = asin_p(s);
|
||||
let cz = cos(th);
|
||||
let spin = P.f[2].x;
|
||||
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
|
||||
|
|
@ -695,7 +695,7 @@ fn ftr_sphere_tex(q: vec3<f32>) -> vec4<f32> {
|
|||
let m1 = P.f[8].xyz;
|
||||
let m2 = P.f[9].xyz;
|
||||
let t = m0 * q.x + m1 * q.y + m2 * q.z;
|
||||
let lat = asin(clamp(t.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(t.y, -1.0, 1.0));
|
||||
return ftr_texel(ftr_lon_u(t.x, t.z), lat / 3.141592653589793 + 0.5);
|
||||
}
|
||||
|
||||
|
|
@ -775,7 +775,7 @@ fn ftr_env_uv(mapping: u32, r: vec3<f32>) -> vec2<f32> {
|
|||
}
|
||||
return vec2<f32>((cr.x + (cr.z + 1.0) * 0.5) / 3.0, (cr.y + (cr.w + 1.0) * 0.5) / 4.0);
|
||||
}
|
||||
let lat = asin(clamp(r.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(r.y, -1.0, 1.0));
|
||||
return vec2<f32>(ftr_lon_u(r.x, r.z), 0.5 + lat / 3.141592653589793);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3<f32> {
|
|||
// vr::equi_px.
|
||||
fn fxv_equi_px(d: vec3<f32>, w: f32, h: f32) -> vec2<f32> {
|
||||
let lon = atan2(d.x, d.z);
|
||||
let lat = asin(clamp(d.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(d.y, -1.0, 1.0));
|
||||
return vec2<f32>((lon / (2.0 * FXV_PI) + 0.5) * w, (0.5 - lat / FXV_PI) * h);
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ fn fxv_equi_at(xi0: i32, yi0: i32, w: i32, h: i32) -> vec4<f32> {
|
|||
yi = 2 * h - 1 - yi;
|
||||
xi += w / 2;
|
||||
}
|
||||
let xm = ((xi % w) + w) % w;
|
||||
let xm = imod(xi, w);
|
||||
return textureLoad(src, vec2<i32>(xm, clamp(yi, 0, h - 1)), 0);
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +233,7 @@ fn fxv_proj_sample(kind: u32, fov: f32, d: vec3<f32>) -> vec4<f32> {
|
|||
return sample_bilinear_clamped(src, x, y);
|
||||
}
|
||||
if (kind == 8u) {
|
||||
let th = acos(clamp(d.y, -1.0, 1.0));
|
||||
let th = acos_p(clamp(d.y, -1.0, 1.0));
|
||||
if (th > FXV_PI / 2.0 + 1e-6) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ fn fxv_proj_sample(kind: u32, fov: f32, d: vec3<f32>) -> vec4<f32> {
|
|||
}
|
||||
if (kind == 4u) {
|
||||
let half = radians(clamp(fov, 1.0, 360.0) / 2.0);
|
||||
let th = acos(clamp(d.z, -1.0, 1.0));
|
||||
let th = acos_p(clamp(d.z, -1.0, 1.0));
|
||||
if (th > half + 1e-6) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
|
|
@ -346,12 +346,12 @@ fn fxv_box_h(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let norm = 1.0 / f32(2 * r + 1);
|
||||
var acc = vec4<f32>(0.0);
|
||||
for (var i = b0 - r; i <= b0 + r; i++) {
|
||||
acc += textureLoad(src, vec2<i32>(((i % w) + w) % w, y), 0);
|
||||
acc += textureLoad(src, vec2<i32>(imod(i, w), y), 0);
|
||||
}
|
||||
for (var x = b0; x < b1; x++) {
|
||||
textureStore(out, vec2<i32>(x, y), acc * norm);
|
||||
let add = textureLoad(src, vec2<i32>((x + r + 1) % w, y), 0);
|
||||
let sub = textureLoad(src, vec2<i32>(((x - r) % w + w) % w, y), 0);
|
||||
let sub = textureLoad(src, vec2<i32>(imod(x - r, w), y), 0);
|
||||
acc += add - sub;
|
||||
}
|
||||
}
|
||||
|
|
@ -362,10 +362,10 @@ fn fxv_pole_at(x0: i32, y0: i32, w: i32, h: i32) -> vec4<f32> {
|
|||
var y = y0;
|
||||
if (y < 0) {
|
||||
y = -1 - y;
|
||||
x = (x + w / 2) % w;
|
||||
x = imod(x + w / 2, w);
|
||||
} else if (y >= h) {
|
||||
y = 2 * h - 1 - y;
|
||||
x = (x + w / 2) % w;
|
||||
x = imod(x + w / 2, w);
|
||||
}
|
||||
return textureLoad(src, vec2<i32>(x, clamp(y, 0, h - 1)), 0);
|
||||
}
|
||||
|
|
@ -413,7 +413,7 @@ fn fxv_wrap_pad(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
sy = 2 * s.y - 1 - sy;
|
||||
sx += s.x / 2;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, vec2<i32>(((sx % s.x) + s.x) % s.x, clamp(sy, 0, s.y - 1)), 0));
|
||||
textureStore(out, p, textureLoad(src, vec2<i32>(imod(sx, s.x), clamp(sy, 0, s.y - 1)), 0));
|
||||
}
|
||||
|
||||
// Per-pixel steps. u[0].x = 0: VR Glow's highlights (u[0].y tint; f[0] = (threshold,
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ fn fxw_pageturn(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
cand[0] = vec4<f32>(FXW_PI * r - d, 1.0, 0.9, 1.0);
|
||||
cand[3] = vec4<f32>(d, 0.0, 1.0, 1.0);
|
||||
} else {
|
||||
let a = asin(clamp(d / r, -1.0, 1.0));
|
||||
let a = asin_p(clamp(d / r, -1.0, 1.0));
|
||||
cand[1] = vec4<f32>(FXW_PI * r - r * a, 1.0, 0.6 + 0.4 * cos(a), 1.0);
|
||||
let psi = a / (0.5 * FXW_PI);
|
||||
cand[2] = vec4<f32>(r * a, 0.0, 1.0 - 0.4 * psi * (0.5 + 0.5 * ldot), 1.0);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if ((flags & 2u) != 0u) {
|
||||
d = vec4<f32>(0.0);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
|
||||
return;
|
||||
}
|
||||
let r0 = P.f[0];
|
||||
|
|
@ -52,7 +52,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if (stencil && (flags & 1u) == 0u) {
|
||||
d = blend_pixel(mode, d, vec4<f32>(0.0), 0.5);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
|
||||
return;
|
||||
}
|
||||
var s = sample(src, sampling, px, py);
|
||||
|
|
@ -60,11 +60,11 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if (s.w > 0.0) {
|
||||
d = d + s * P.f[3].y;
|
||||
}
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
|
||||
return;
|
||||
}
|
||||
if (s.w <= 0.0 && !stencil) {
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
|
||||
return;
|
||||
}
|
||||
let op = P.f[3].x;
|
||||
|
|
@ -75,7 +75,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if (mode == 1u || mode == 2u) {
|
||||
n = hash_noise(u32(x), u32(y), seed);
|
||||
}
|
||||
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z);
|
||||
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z, P.f[3].w);
|
||||
}
|
||||
|
||||
// Blend `src` (same size) onto `aux` (Image::blend_from).
|
||||
|
|
@ -91,7 +91,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
let d = textureLoad(aux, p, 0);
|
||||
var s = textureLoad(src, p, 0);
|
||||
if (s.w <= 0.0 && !is_stencil(mode)) {
|
||||
store_q(p, d, P.f[0].y);
|
||||
store_q(p, d, P.f[0].y, P.f[0].z);
|
||||
return;
|
||||
}
|
||||
s = s * P.f[0].x;
|
||||
|
|
@ -99,7 +99,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if (mode == 1u || mode == 2u) {
|
||||
n = hash_noise(gid.x, gid.y, P.u[0].z);
|
||||
}
|
||||
store_q(p, blend_pixel(mode, d, s, n), P.f[0].y);
|
||||
store_q(p, blend_pixel(mode, d, s, n), P.f[0].y, P.f[0].z);
|
||||
}
|
||||
|
||||
// Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma,
|
||||
|
|
@ -179,24 +179,24 @@ fn quantize(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
if (p.x >= dims.x || p.y >= dims.y) {
|
||||
return;
|
||||
}
|
||||
textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x));
|
||||
textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x, P.f[0].y));
|
||||
}
|
||||
|
||||
fn quantize_px(s: vec4<f32>, levels: f32) -> vec4<f32> {
|
||||
let inv = 1.0 / levels;
|
||||
let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv;
|
||||
fn quantize_px(s: vec4<f32>, levels: f32, inv: f32) -> vec4<f32> {
|
||||
// Use the CPU-rounded reciprocal to avoid backend-dependent division.
|
||||
let a = floor(clamp(s.w, 0.0, 1.0) * levels + 0.5) * inv;
|
||||
var o = vec4<f32>(0.0, 0.0, 0.0, a);
|
||||
o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv;
|
||||
o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv;
|
||||
o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv;
|
||||
o.x = floor(clamp(s.x, 0.0, a) * levels + 0.5) * inv;
|
||||
o.y = floor(clamp(s.y, 0.0, a) * levels + 0.5) * inv;
|
||||
o.z = floor(clamp(s.z, 0.0, a) * levels + 0.5) * inv;
|
||||
return o;
|
||||
}
|
||||
|
||||
// Store a compositing result, clamped and quantised to `levels` when > 0 (8/16 bpc: the comp
|
||||
// after a layer, fused into the layer's composite).
|
||||
fn store_q(p: vec2<i32>, v: vec4<f32>, levels: f32) {
|
||||
fn store_q(p: vec2<i32>, v: vec4<f32>, levels: f32, inv: f32) {
|
||||
if (levels > 0.0) {
|
||||
textureStore(out, p, quantize_px(v, levels));
|
||||
textureStore(out, p, quantize_px(v, levels, inv));
|
||||
} else {
|
||||
textureStore(out, p, v);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ fn adv_sky(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|||
// shade::equirect_uv.
|
||||
let pi = 3.141592653589793;
|
||||
var u = 0.5 + (atan2(dir.x, dir.z) + P.f[2].w) / (2.0 * pi);
|
||||
let v = acos(clamp(-dir.y, -1.0, 1.0)) / pi;
|
||||
let v = acos_p(clamp(-dir.y, -1.0, 1.0)) / pi;
|
||||
u = u - floor(u);
|
||||
let iw = P.f[4].x;
|
||||
let ih = P.f[4].y;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,31 @@ use effectcraft_time::{FrameRate, Tick};
|
|||
|
||||
use crate::Gpu;
|
||||
|
||||
/// One GPU test at a time in this process: the calling test thread holds a process-wide lock
|
||||
/// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa
|
||||
/// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a
|
||||
/// thread waits on it for more than a few seconds ("Could not lock adapter context"), and
|
||||
/// concurrent devices on llvmpipe can crash. Every test helper that hands out a device calls this
|
||||
/// first; calling it again on the same thread is a no-op.
|
||||
pub(crate) fn hold_gpu_lock() {
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static HELD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
|
||||
}
|
||||
HELD.with(|h| {
|
||||
let mut h = h.borrow_mut();
|
||||
if h.is_none() {
|
||||
*h = Some(GPU_LOCK.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
hold_gpu_lock();
|
||||
G.get_or_init(|| {
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
|
|
@ -38,6 +61,20 @@ pub(crate) fn gpu() -> Option<&'static Gpu> {
|
|||
.as_ref()
|
||||
}
|
||||
|
||||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||||
pub(crate) fn on_gl() -> bool {
|
||||
gpu().is_some_and(|g| g.ctx.name.ends_with("(Gl)"))
|
||||
}
|
||||
|
||||
/// On GL, the fraction of pixels allowed past the tolerance: pixels on a decision boundary (a
|
||||
/// hex cell's edge, a threshold, a corner pin's coverage edge, a median's rank tie) may land on
|
||||
/// the other side, since GL drivers may divide through a reciprocal and GLSL's `fma` need not be
|
||||
/// fused (so `fxs_qdiv`-style corrections don't apply). Measured on llvmpipe: at most 0.53 % (CC
|
||||
/// HexTile at half resolution, many cell edges in a small frame). A wrong wrap (signed `%`) showed
|
||||
/// in 3–70 % of pixels and still fails; a precision loss may show in fewer (Mesa's `asin`: 0.3–3 %),
|
||||
/// so the shaders avoid imprecise builtins themselves (`asin_p`) rather than lean on this.
|
||||
pub(crate) const GL_BOUNDARY_FLIPS: f64 = 0.01;
|
||||
|
||||
/// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and
|
||||
/// fully transparent regions (different per item).
|
||||
pub(crate) struct Pattern;
|
||||
|
|
@ -205,6 +242,7 @@ pub(crate) fn check(label: &str, d: Option<Diff>, allow: f64) {
|
|||
let frac = d.over as f64 / d.total as f64;
|
||||
// Quantised depths: rounding-boundary flips (see the module docs).
|
||||
let (allow, cap) = if d.quantized && allow == 0.0 { (0.003, 4.0 / 255.0 + 1e-6) } else { (allow, f32::INFINITY) };
|
||||
let (allow, cap) = if on_gl() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) };
|
||||
assert!(frac <= allow && d.max <= cap, "{label}: {} of {} pixels over tolerance (max {:.6}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
if d.over > 0 {
|
||||
eprintln!("{label}: {} of {} pixels over tolerance (max {:.6})", d.over, d.total, d.max);
|
||||
|
|
@ -403,7 +441,15 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool)
|
|||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
|
||||
let d = diff(&cpu.img, &out.img, 1e-3);
|
||||
assert!(d.over == 0, "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
|
||||
assert!(
|
||||
d.over <= allow,
|
||||
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
|
||||
d.over,
|
||||
d.total,
|
||||
d.max,
|
||||
d.worst
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +580,30 @@ fn registry_badges_match_the_gpu_implementation() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantization_preserves_cpu_levels_and_half_step_neighbors() {
|
||||
let Some(g) = gpu() else { return };
|
||||
for levels in [255.0_f32, 32768.0] {
|
||||
let mut values = vec![0.0, 1.0];
|
||||
for i in 0..255 {
|
||||
let half = (i as f32 + 0.5) / levels;
|
||||
values.extend([half.next_down(), half, half.next_up()]);
|
||||
}
|
||||
let mut cpu = Image::new(values.len() as u32, 1);
|
||||
for (p, v) in cpu.data.iter_mut().zip(values) {
|
||||
*p = [v, v, v, 1.0];
|
||||
}
|
||||
let input = g.ctx.upload_image(&cpu).unwrap();
|
||||
effectcraft_render::color::quantize(&mut cpu, levels);
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
let out = crate::ops::quantize(&mut e, &input, levels);
|
||||
let bytes = e.read_texture(&out.texture, out.width, out.height, 16).unwrap();
|
||||
for (i, (want, got)) in cpu.data.iter().flatten().zip(bytes.as_chunks::<4>().0).enumerate() {
|
||||
let actual = f32::from_le_bytes(*got);
|
||||
assert_eq!(actual, *want, "levels {levels}, component {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn backend_selection_and_display_frames() {
|
||||
let Some(g) = gpu() else { return };
|
||||
|
|
@ -554,7 +624,7 @@ fn backend_selection_and_display_frames() {
|
|||
let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame");
|
||||
let bytes = g.read_display(&f).expect("readback");
|
||||
let cpu = Renderer::new(&s.p, &Pattern, opts()).comp_frame_cpu(s.cid, Tick::ZERO);
|
||||
for (p, q) in cpu.data.iter().zip(bytes.chunks_exact(4)) {
|
||||
for (p, q) in cpu.data.iter().zip(bytes.as_chunks::<4>().0.iter()) {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let want = [p[0].clamp(0.0, a), p[1].clamp(0.0, a), p[2].clamp(0.0, a), a].map(|v| (v * 255.0 + 0.5) as u8);
|
||||
for k in 0..4 {
|
||||
|
|
@ -604,3 +674,25 @@ fn wgsl_float_literals_fit_f32() {
|
|||
}
|
||||
assert!(checked > 0);
|
||||
}
|
||||
|
||||
/// A device created with WebGL2's limits (what egui-wgpu asks for on GL, as the desktop app on
|
||||
/// FreeBSD gets): the compositor declines it instead of building pipelines that fail validation
|
||||
/// (wgpu's default error handler panics here; release builds would only log it).
|
||||
#[test]
|
||||
fn devices_with_webgl2_limits_are_declined() {
|
||||
hold_gpu_lock();
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { return };
|
||||
let desc = wgpu::DeviceDescriptor { required_limits: wgpu::Limits::downlevel_webgl2_defaults(), ..Default::default() };
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&desc)) else { return };
|
||||
let err = crate::context::GpuContext::new(&adapter, device, queue).err().expect("declined");
|
||||
assert!(err.contains("max_storage_buffers_per_shader_stage"), "{err}");
|
||||
}
|
||||
|
||||
/// GPU work inside the video memory guard returns its result when the device has room (#106).
|
||||
#[test]
|
||||
fn within_memory_returns_the_work_when_there_is_room() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture");
|
||||
assert_eq!((img.width, img.height), (64, 32));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ use effectcraft_time::{FrameRate, Tick};
|
|||
|
||||
use crate::Gpu;
|
||||
|
||||
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
|
||||
fn gpu() -> Option<Gpu> {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
|
||||
|
|
@ -527,3 +529,24 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
|||
assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter whose render targets don't qualify (`R32Float` was not renderable on GL's llvmpipe;
|
||||
/// the pipelines failed validation, which release builds only log): Advanced 3D renders on the
|
||||
/// CPU, the GPU compositor's frame matches the CPU's, and no render pipeline is built (a
|
||||
/// validation error panics under test).
|
||||
#[test]
|
||||
fn adapters_that_cannot_rasterise_render_advanced_3d_on_the_cpu() {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let Some(mut ctx) = crate::context::GpuContext::headless() else { return };
|
||||
ctx.adv3d_raster = false;
|
||||
let g = Gpu::from_context(ctx);
|
||||
let (p, cid) = scene_project();
|
||||
let s = scene(&p, cid);
|
||||
assert!(g.raster_3d(&s).is_none(), "no GPU raster");
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||||
let worst = cpu.data.iter().zip(&gimg.data).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 2.0 / 255.0, "GPU compositor with CPU Advanced 3D vs CPU: worst {worst}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
|
|||
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
// Also no other GPU test while it runs (see `hold_gpu_lock`).
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = G.get_or_init(|| pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok()).as_ref()?;
|
||||
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,3 +228,24 @@ fn layer_inputs() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_preserves_positive_subepsilon_alpha() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.channel.arithmetic").unwrap();
|
||||
let size = [1.0, 1.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
|
||||
params.values.insert("operator".into(), Value::Enum(3));
|
||||
for key in ["redValue", "greenValue", "blueValue"] {
|
||||
params.values.insert(key.into(), n(0.0));
|
||||
}
|
||||
params.values.insert("clip".into(), Value::Bool(false));
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: size, seed: 0, adjustment: false, env: Default::default() };
|
||||
let img = effectcraft_raster::Image::filled(1, 1, [1.0, 0.0, 0.0, 5e-7]);
|
||||
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
|
||||
assert_eq!(cpu.img.get(0, 0), [1.0, 0.0, 0.0, 5e-7]);
|
||||
assert_eq!(out.img.get(0, 0), cpu.img.get(0, 0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,3 +202,26 @@ fn echo_and_posterize_time() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dust_threshold_on_quantized_input() {
|
||||
let Some(g) = crate::tests::gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.noise.dustscratches").unwrap();
|
||||
let size = [70.0, 44.0];
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
|
||||
params.values.insert("radius".into(), n(3.0));
|
||||
params.values.insert("threshold".into(), n(10.0));
|
||||
let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.25, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
|
||||
for seed in 1..32 {
|
||||
let mut img = crate::tests::pattern(seed, 70, 44);
|
||||
effectcraft_render::color::quantize(&mut img, 255.0);
|
||||
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let cpu = (spec.render)(&ctx(), buf.clone());
|
||||
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
|
||||
for (i, (a, b)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
|
||||
let d = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0_f32, f32::max);
|
||||
assert!(d < 1e-6, "seed {seed} pixel {i} input {:?} CPU {a:?} GPU {b:?}", buf.img.data[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ impl Exporter for FileExporter {
|
|||
/// A new session with media, import, expressions, scripting and export enabled.
|
||||
pub fn session() -> Session {
|
||||
Session {
|
||||
// The desktop app, the CLI and the MCP server share installed Roto Brush models.
|
||||
models_dir: config_dir().map(|d| d.join("models")),
|
||||
exporter: Some(Arc::new(FileExporter::default())),
|
||||
footage: Arc::new(effectcraft_media::MediaPool::new()),
|
||||
importer: Some(Arc::new(MediaImporter)),
|
||||
|
|
@ -93,6 +95,24 @@ pub fn session() -> Session {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub 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"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
|
@ -209,8 +229,10 @@ mod tests {
|
|||
s.poll_render();
|
||||
let mut got = got.lock().unwrap().clone();
|
||||
got.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let names: Vec<&str> = got.iter().map(|(p, _)| p.as_str()).collect();
|
||||
assert_eq!(names, [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]);
|
||||
let names: Vec<std::path::PathBuf> = got.iter().map(|(p, _)| std::path::PathBuf::from(p)).collect();
|
||||
let expected = [format!("{dir}/a.gif"), format!("{dir}/seq_00.png"), format!("{dir}/seq_01.png"), format!("{dir}/seq_02.png")]
|
||||
.map(|p| std::path::absolute(p).unwrap());
|
||||
assert_eq!(names, expected);
|
||||
assert!(got[0].1.starts_with(b"GIF89a"));
|
||||
assert!(got[1].1.starts_with(b"\x89PNG"));
|
||||
assert!(!std::path::Path::new(dir).exists());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
//! Original scalar keyframe cases for comparison with AE through AEsync.
|
||||
use effectcraft_keyframe::{Ease, Interp, Keyframe, Value, evaluate};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let times: Vec<f64> = (0..=20).map(|i| f64::from(i) / 20.0).collect();
|
||||
let mut cases = Vec::new();
|
||||
for (name, from, to, out_speed, out_inf, in_speed, in_inf) in [
|
||||
("easy", 0.0, 100.0, 0.0, 1.0 / 3.0, 0.0, 1.0 / 3.0),
|
||||
("high_influence", 0.0, 100.0, 0.0, 0.8, 0.0, 0.8),
|
||||
("asymmetric", 0.0, 100.0, 20.0, 0.8, 80.0, 0.6),
|
||||
("overshoot", 0.0, 100.0, 400.0, 0.4, -100.0, 0.4),
|
||||
("descending", 100.0, 0.0, -20.0, 0.6, -80.0, 0.8),
|
||||
("equal_endpoints", 50.0, 50.0, 100.0, 0.4, -100.0, 0.4),
|
||||
("maximum_influence", 0.0, 100.0, 0.0, 1.0, 0.0, 1.0),
|
||||
("minimum_maximum", 0.0, 100.0, 0.0, 0.001, 0.0, 1.0),
|
||||
] {
|
||||
let mut a = Keyframe::new(Tick::ZERO, Value::Scalar(from));
|
||||
let mut b = Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(to));
|
||||
a.out_interp = Interp::Bezier;
|
||||
b.in_interp = Interp::Bezier;
|
||||
a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }];
|
||||
b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }];
|
||||
let keys = [a, b];
|
||||
let values: Vec<f64> = times
|
||||
.iter()
|
||||
.map(|t| evaluate(&keys, Tick::from_seconds_f64(*t), false).map(|v| v.as_f64()).ok_or("missing keyframe evaluation"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
cases.push(json!({"name":name,"from":from,"to":to,"outSpeed":out_speed,"outInfluence":out_inf * 100.0,"inSpeed":in_speed,"inInfluence":in_inf * 100.0,"times":times,"values":values}));
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&json!({"cases":cases}))?);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
//! (pixels/second), evaluated through an arc-length table.
|
||||
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod text_doc;
|
||||
pub mod value;
|
||||
|
||||
|
|
@ -179,16 +181,22 @@ fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 {
|
|||
if x >= x3 {
|
||||
return 1.0;
|
||||
}
|
||||
if x1 == x3 && x2 == x0 {
|
||||
// Full influence on both sides has a flat time derivative at the midpoint.
|
||||
// Its exact inverse avoids cancellation and residual-only Newton convergence there.
|
||||
let progress = (x - x0) / (x3 - x0);
|
||||
return 0.5 + ((progress - 0.5) * 0.25).cbrt();
|
||||
}
|
||||
let mut u = (x - x0) / (x3 - x0);
|
||||
for _ in 0..8 {
|
||||
let f = bez(x0, x1, x2, x3, u) - x;
|
||||
if f.abs() < 1e-12 {
|
||||
return u;
|
||||
}
|
||||
let d = bez_d(x0, x1, x2, x3, u);
|
||||
if d.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
if (f / d).abs() < 1e-12 {
|
||||
return u;
|
||||
}
|
||||
let n = u - f / d;
|
||||
if !(0.0..=1.0).contains(&n) {
|
||||
break;
|
||||
|
|
@ -210,12 +218,8 @@ fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 {
|
|||
/// Fraction of progress 0..1 through a 1D Bezier temporal segment whose value goes from 0 to 1,
|
||||
/// with normalised out/in speeds (speed × duration / Δvalue) and influences.
|
||||
pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f64, x: f64) -> f64 {
|
||||
let (mut i0, mut i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
|
||||
if i0 + i1 > 1.0 {
|
||||
let k = 1.0 / (i0 + i1);
|
||||
i0 *= k;
|
||||
i1 *= k;
|
||||
}
|
||||
// The time handles may cross. AE keeps both influences; rescaling them changes the ease.
|
||||
let (i0, i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
|
||||
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
|
||||
bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u)
|
||||
}
|
||||
|
|
@ -289,7 +293,8 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
|||
|
||||
/// Effective spatial tangents of key `i` (auto or stored).
|
||||
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||||
if keys[i].spatial_auto { auto_spatial(keys, i) } else { (keys[i].spatial_in, keys[i].spatial_out) }
|
||||
let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) };
|
||||
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
|
||||
}
|
||||
|
||||
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
|
||||
|
|
@ -342,11 +347,12 @@ pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool
|
|||
|
||||
/// Arc length of the spatial segment `i` → `i + 1` (motion-path length).
|
||||
pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 {
|
||||
let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return 0.0 };
|
||||
let Some(next) = i.checked_add(1) else { return 0.0 };
|
||||
let (Some(a), Some(b)) = (keys.get(i), keys.get(next)) else { return 0.0 };
|
||||
let p0 = v3(&a.value);
|
||||
let p3 = v3(&b.value);
|
||||
let (_, out_t) = spatial_tangents(keys, i);
|
||||
let (in_t, _) = spatial_tangents(keys, i + 1);
|
||||
let (in_t, _) = spatial_tangents(keys, next);
|
||||
SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length()
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +394,9 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool {
|
|||
let (t0, t1) = (keys[a].time, keys[b].time);
|
||||
for (o, i) in (a + 1..b).enumerate() {
|
||||
let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 };
|
||||
let nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() as i64);
|
||||
let span = i128::from(t1.0) - i128::from(t0.0);
|
||||
let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128;
|
||||
let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||||
if nt != keys[i].time {
|
||||
keys[i].time = nt;
|
||||
changed = true;
|
||||
|
|
@ -408,7 +416,9 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool {
|
|||
pub fn time_reverse(keys: &mut [Keyframe]) {
|
||||
let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return };
|
||||
for k in keys.iter_mut() {
|
||||
k.time = Tick(first.0 + last.0 - k.time.0);
|
||||
// Imported Tick values may span i64's full range; keep the intermediate exact.
|
||||
let reversed = i128::from(first.0) + i128::from(last.0) - i128::from(k.time.0);
|
||||
k.time = Tick(reversed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
|
|
@ -480,12 +490,7 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
|
|||
let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) };
|
||||
let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) };
|
||||
// Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur).
|
||||
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||||
if i0 + i1 > 1.0 {
|
||||
let k = 1.0 / (i0 + i1);
|
||||
i0 *= k;
|
||||
i1 *= k;
|
||||
}
|
||||
let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||||
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
|
||||
let v = bez(ca[d], ca[d] + eo.speed * i0 * dur, cb[d] - ei.speed * i1 * dur, cb[d], u);
|
||||
out.push(v);
|
||||
|
|
@ -522,6 +527,8 @@ pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
|
|||
k.spatial_in = old.spatial_in;
|
||||
k.spatial_out = old.spatial_out;
|
||||
k.spatial_auto = old.spatial_auto;
|
||||
k.spatial_continuous = old.spatial_continuous;
|
||||
k.roving = old.roving;
|
||||
k.label = old.label;
|
||||
keys[i] = k;
|
||||
i
|
||||
|
|
@ -560,6 +567,94 @@ mod tests {
|
|||
Tick::from_seconds_f64(x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_spatial_keys_have_no_tangents_or_length() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))];
|
||||
for i in [1, usize::MAX] {
|
||||
assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3]));
|
||||
assert_eq!(spatial_segment_length(&keys, i), 0.0);
|
||||
}
|
||||
assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3]));
|
||||
assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_reverse_handles_deserialized_tick_extremes() {
|
||||
// Tick's public serde representation can contain values beyond from_seconds_f64's cap.
|
||||
for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] {
|
||||
let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))];
|
||||
time_reverse(&mut keys);
|
||||
assert_eq!(keys[0].time, Tick(first));
|
||||
assert_eq!(keys[0].value, Value::Scalar(2.0));
|
||||
assert_eq!(keys[1].time, Tick(last));
|
||||
assert_eq!(keys[1].value, Value::Scalar(1.0));
|
||||
time_reverse(&mut keys);
|
||||
assert_eq!(keys[0].value, Value::Scalar(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roving_handles_deserialized_tick_extremes() {
|
||||
let mut keys = vec![
|
||||
Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])),
|
||||
Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])),
|
||||
Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])),
|
||||
];
|
||||
for key in &mut keys {
|
||||
key.spatial_auto = false;
|
||||
}
|
||||
keys[1].roving = true;
|
||||
assert!(retime_roving(&mut keys, true));
|
||||
assert_eq!(keys[1].time, Tick::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_key_preserves_spatial_metadata() {
|
||||
let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0]));
|
||||
key.roving = true;
|
||||
key.spatial_continuous = true;
|
||||
let mut keys = vec![key];
|
||||
set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0])));
|
||||
assert!(keys[0].roving);
|
||||
assert!(keys[0].spatial_continuous);
|
||||
assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_ease_matches_live_ae_with_overlapping_influences() {
|
||||
// Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05.
|
||||
// AE retains each handle's requested influence even when their sum exceeds 100%.
|
||||
for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [
|
||||
(0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]),
|
||||
(0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]),
|
||||
(100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]),
|
||||
] {
|
||||
let mut a = Keyframe::new(s(0.0), Value::Scalar(from));
|
||||
let mut b = Keyframe::new(s(1.0), Value::Scalar(to));
|
||||
a.out_interp = Interp::Bezier;
|
||||
b.in_interp = Interp::Bezier;
|
||||
a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }];
|
||||
b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }];
|
||||
for (time, expected) in samples {
|
||||
let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64();
|
||||
assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}");
|
||||
let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time);
|
||||
assert!((from + normalized * (to - from) - expected).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_influence_resolves_flat_midpoint_time_curve() {
|
||||
for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] {
|
||||
// At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly.
|
||||
let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt();
|
||||
let expected = bez(0.0, 0.0, 1.0, 1.0, u);
|
||||
let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time);
|
||||
assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
|
||||
#[test]
|
||||
fn gradients_coerce_from_plain_json() {
|
||||
|
|
|
|||
|
|
@ -145,12 +145,8 @@ fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec<f64>, Vec<f6
|
|||
};
|
||||
let lin = Ease { speed: dv / dur, influence: 1.0 / 3.0 };
|
||||
let (eo, ei) = (eo.unwrap_or(lin), ei.unwrap_or(lin));
|
||||
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||||
if i0 + i1 > 1.0 {
|
||||
let k = 1.0 / (i0 + i1);
|
||||
i0 *= k;
|
||||
i1 *= k;
|
||||
}
|
||||
// Crossing time handles must retain the independently specified influences.
|
||||
let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||||
let (y0, y1) = if dv.abs() > 1e-12 { (eo.speed * i0 * dur / dv, 1.0 - ei.speed * i1 * dur / dv) } else { (0.0, 1.0) };
|
||||
ox.push(r6(i0));
|
||||
oy.push(r6(y0));
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ fn easing_converts_exactly_both_ways() {
|
|||
assert!((k0["o"]["x"][0].as_f64().unwrap() - 0.6).abs() < 1e-9);
|
||||
assert!((k0["o"]["y"][0].as_f64().unwrap() - 300.0 * 0.6 * 0.8 / 180.0).abs() < 1e-5);
|
||||
assert_eq!(json["layers"][0]["ks"]["r"]["k"][1]["h"], json!(1));
|
||||
let scale = &json["layers"][0]["ks"]["s"]["k"][0];
|
||||
assert!((scale["o"]["x"][0].as_f64().unwrap() - 0.9).abs() < 1e-9);
|
||||
assert!((scale["i"]["x"][0].as_f64().unwrap() - 2.0 / 3.0).abs() < 1e-6);
|
||||
assert!((scale["o"]["y"][0].as_f64().unwrap() + 0.1125).abs() < 1e-9);
|
||||
let rq = &q.comp(nc).unwrap().layers[0];
|
||||
let rk = &rq.props.prop("transform/rotation").unwrap().keys;
|
||||
assert!((rk[0].out_ease[0].speed - 300.0).abs() < 1e-3 && (rk[0].out_ease[0].influence - 0.6).abs() < 1e-6, "{:?}", rk[0].out_ease);
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
|
|||
PixelData::Rgba8(d) => {
|
||||
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
let src = &d[y * w * 4..(y + 1) * w * 4];
|
||||
for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) {
|
||||
for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
|
||||
*o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
|
||||
}
|
||||
});
|
||||
|
|
@ -69,7 +69,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -
|
|||
// linear premultiplied → encoded premultiplied
|
||||
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
let src = &d[y * w * 4..(y + 1) * w * 4];
|
||||
for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) {
|
||||
for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) {
|
||||
let a = s[3];
|
||||
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
|
||||
let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a];
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ impl MediaPool {
|
|||
f.seek(SeekFrom::Start(s0 as u64 * 8)).ok()?;
|
||||
let mut bytes = vec![0u8; n * 8];
|
||||
f.read_exact(&mut bytes).ok()?;
|
||||
for (o, c) in out.iter_mut().zip(bytes.chunks_exact(4)) {
|
||||
for (o, c) in out.iter_mut().zip(bytes.as_chunks::<4>().0.iter()) {
|
||||
*o = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
}
|
||||
Some(out)
|
||||
|
|
|
|||
|
|
@ -393,7 +393,9 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
|||
let inverse_bind = match idx(s, "inverseBindMatrices") {
|
||||
Some(a) => {
|
||||
let (f, _) = d.floats(a)?;
|
||||
f.chunks_exact(16)
|
||||
f.as_chunks::<16>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let mut m = [[0.0; 4]; 4];
|
||||
for c in 0..4 {
|
||||
|
|
@ -495,29 +497,29 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
|||
let attrs = p.get("attributes").ok_or_else(|| perr("primitive without attributes"))?;
|
||||
let Some(pa) = idx(attrs, "POSITION") else { return Ok(None) };
|
||||
let (pos, _) = d.floats(pa)?;
|
||||
let positions: Vec<[f32; 3]> = pos.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
let positions: Vec<[f32; 3]> = pos.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect();
|
||||
let n = positions.len();
|
||||
let vec3s = |a: Option<usize>| -> Result<Vec<[f32; 3]>> {
|
||||
Ok(match a {
|
||||
Some(a) => d.floats(a)?.0.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(),
|
||||
Some(a) => d.floats(a)?.0.as_chunks::<3>().0.iter().map(|c| [c[0], c[1], c[2]]).collect(),
|
||||
None => vec![],
|
||||
})
|
||||
};
|
||||
let mut normals = vec3s(idx(attrs, "NORMAL"))?;
|
||||
let uvs: Vec<[f32; 2]> = match idx(attrs, "TEXCOORD_0") {
|
||||
Some(a) => d.floats(a)?.0.chunks_exact(2).map(|c| [c[0], c[1]]).collect(),
|
||||
Some(a) => d.floats(a)?.0.as_chunks::<2>().0.iter().map(|c| [c[0], c[1]]).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
let tangents: Vec<[f32; 4]> = match idx(attrs, "TANGENT") {
|
||||
Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(),
|
||||
Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
let joints: Vec<[u16; 4]> = match idx(attrs, "JOINTS_0") {
|
||||
Some(a) => d.ints(a)?.chunks_exact(4).map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(),
|
||||
Some(a) => d.ints(a)?.as_chunks::<4>().0.iter().map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
let weights: Vec<[f32; 4]> = match idx(attrs, "WEIGHTS_0") {
|
||||
Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(),
|
||||
Some(a) => d.floats(a)?.0.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect(),
|
||||
None => vec![],
|
||||
};
|
||||
let raw: Vec<u32> = match idx(p, "indices") {
|
||||
|
|
@ -540,7 +542,7 @@ fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primit
|
|||
indices.extend([raw[0], raw[i - 1], raw[i]]);
|
||||
}
|
||||
}
|
||||
_ => indices.extend(raw.chunks_exact(3).flatten()),
|
||||
_ => indices.extend(raw.as_chunks::<3>().0.iter().flatten()),
|
||||
}
|
||||
if normals.len() != n {
|
||||
normals.clear();
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ impl Primitive {
|
|||
/// Smooth vertex normals from the triangles (area weighted), for meshes without normals.
|
||||
pub fn compute_normals(&mut self) {
|
||||
let mut n = vec![[0.0f32; 3]; self.positions.len()];
|
||||
for t in self.indices.chunks_exact(3) {
|
||||
for t in self.indices.as_chunks::<3>().0 {
|
||||
let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]);
|
||||
let f = cross(sub(b, a), sub(c, a));
|
||||
for &i in t {
|
||||
|
|
@ -185,7 +185,7 @@ impl Primitive {
|
|||
/// primitive has none).
|
||||
pub fn flat_normals(&mut self) {
|
||||
let mut p = Primitive { material: self.material, ..Default::default() };
|
||||
for t in self.indices.chunks_exact(3) {
|
||||
for t in self.indices.as_chunks::<3>().0 {
|
||||
let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]);
|
||||
let f = normalize(cross(sub(b, a), sub(c, a))).unwrap_or([0.0, 0.0, 1.0]);
|
||||
for &i in t {
|
||||
|
|
@ -213,7 +213,7 @@ impl Primitive {
|
|||
}
|
||||
let mut tan = vec![[0.0f32; 3]; n];
|
||||
let mut bit = vec![[0.0f32; 3]; n];
|
||||
for t in self.indices.chunks_exact(3) {
|
||||
for t in self.indices.as_chunks::<3>().0 {
|
||||
let (i0, i1, i2) = (t[0] as usize, t[1] as usize, t[2] as usize);
|
||||
let (p0, p1, p2) = (self.positions[i0], self.positions[i1], self.positions[i2]);
|
||||
let (w0, w1, w2) = (self.uvs[i0], self.uvs[i1], self.uvs[i2]);
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
|||
let (_, prim, map, need) = &mut prims[slot];
|
||||
let mut ids = vec![];
|
||||
for (vi, ti, ni) in refs {
|
||||
let k = Key { v: vi, t: ti.map_or(u32::MAX, |x| x), n: ni.map_or(u32::MAX, |x| x), s };
|
||||
let k = Key { v: vi, t: ti.unwrap_or(u32::MAX), n: ni.unwrap_or(u32::MAX), s };
|
||||
let id = *map.entry(k).or_insert_with(|| {
|
||||
prim.positions.push(v[vi as usize]);
|
||||
prim.uvs.push(ti.map_or([0.0, 0.0], |t| vt[t as usize]));
|
||||
|
|
|
|||
|
|
@ -198,9 +198,9 @@ fn compute_caps(m: &Mode) -> Vec<u8> {
|
|||
for c in 1..=2i32 {
|
||||
for j in 0..nb {
|
||||
let mut n0 = EBANDS[j + 1] - EBANDS[j];
|
||||
let max_bits;
|
||||
if n0 << i == 1 {
|
||||
max_bits = c * (1 + MAX_FINE_BITS) << BITRES;
|
||||
|
||||
let max_bits = if n0 << i == 1 {
|
||||
c * (1 + MAX_FINE_BITS) << BITRES
|
||||
} else {
|
||||
let mut lm0 = 0;
|
||||
if n0 > 2 {
|
||||
|
|
@ -241,8 +241,8 @@ fn compute_caps(m: &Mode) -> Vec<u8> {
|
|||
let den = (ndof - 1) << BITRES;
|
||||
let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS);
|
||||
mb += c * qb << BITRES;
|
||||
max_bits = mb;
|
||||
}
|
||||
mb
|
||||
};
|
||||
let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i);
|
||||
let v = (4 * max_bits / w) - 64;
|
||||
caps[((i * 2 + c - 1) as usize) * nb + j] = v.clamp(0, 255) as u8;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ fn ffmpeg_decode(exe: &str, codec: &str, file: &[u8], channels: usize, tag: &str
|
|||
// timestamping, not the bitstream, so it is the one message tolerated.
|
||||
let errors: Vec<&str> = stderr.lines().filter(|l| !l.trim().is_empty() && !l.contains("non monotonically increasing dts to muxer")).collect();
|
||||
assert!(errors.is_empty(), "ffmpeg ({codec}) reported errors: {errors:?}");
|
||||
Some(out.stdout.chunks_exact(4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect())
|
||||
Some(out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue