mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Merge main into #1726; clean merge, no conflicts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5a5de6f71d
1033 files changed
+118611
-53288
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,3 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev libasound2-dev
|
||||
@@ -2,12 +2,10 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
@@ -15,6 +13,8 @@ on:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -22,3 +22,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
@@ -10,15 +10,14 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Track shared CI improvements on main.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
timeout-minutes: 120
|
||||
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
secrets: inherit
|
||||
@@ -28,8 +28,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
install-relative-path: Programs/ArtCraft/EffectCraft
|
||||
install-scope: perUser
|
||||
@@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
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).
|
||||
|
||||
+23
-1
@@ -1,9 +1,28 @@
|
||||
# Attribution
|
||||
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has
|
||||
a `<file>.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe
|
||||
iconography, images or other Adobe assets; open licences only; every asset attributed.
|
||||
`cargo xtask assets` checks that this index and the sidecars cover every asset in the repository.
|
||||
The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded
|
||||
by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the
|
||||
`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts.
|
||||
|
||||
## Bundled dependency fonts
|
||||
|
||||
These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at
|
||||
`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository.
|
||||
egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and
|
||||
`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices
|
||||
and licence texts are in [`NOTICE`](NOTICE).
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) |
|
||||
|
||||
## Asset files
|
||||
|
||||
@@ -15,6 +34,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
|
||||
| `assets/fonts/NotoSansSymbols2-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/symbols | OFL-1.1 (`assets/fonts/OFL-NotoSansSymbols2.txt`) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
|
||||
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
@@ -69,6 +89,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids
|
||||
and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`).
|
||||
- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker):
|
||||
`crates/engine/presets/behaviors.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
|
||||
@@ -16,11 +16,13 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
||||
- **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`).
|
||||
- **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32.
|
||||
- **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`).
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id.
|
||||
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
|
||||
- **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
|
||||
|
||||
## Running and looking at the app
|
||||
|
||||
@@ -7,12 +7,36 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
|
||||
|
||||
You need [Rust](https://rustup.rs/) 1.95 or newer.
|
||||
|
||||
On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA
|
||||
pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime
|
||||
library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile
|
||||
ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking
|
||||
error; `cargo xtask ci` probes pkg-config up front and names the package:
|
||||
|
||||
```sh
|
||||
sudo dnf install alsa-lib-devel # Fedora
|
||||
sudo apt install libasound2-dev # Debian / Ubuntu
|
||||
sudo pacman -S alsa-lib # Arch
|
||||
```
|
||||
|
||||
If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc`
|
||||
whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs
|
||||
that variable set.
|
||||
|
||||
```sh
|
||||
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
|
||||
cargo test --workspace
|
||||
cargo xtask ci # what every commit must pass
|
||||
```
|
||||
|
||||
The default `dev` profile keeps debug info to line tables: builds are fast and panics/backtraces
|
||||
still name the file and line, but a debugger cannot show local variables. For an interactive
|
||||
debugger session use the `dev-full` profile, which is the same build with full debug info:
|
||||
|
||||
```sh
|
||||
cargo run --profile dev-full -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
|
||||
|
||||
Generated
+989
-347
File diff suppressed because it is too large.
Load diff
+45
-9
@@ -4,14 +4,17 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/effectcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# internal
|
||||
effectcraft-time = { path = "crates/time" }
|
||||
effectcraft-geom = { path = "crates/geom" }
|
||||
@@ -51,6 +54,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
# std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there).
|
||||
web-time = "1"
|
||||
@@ -99,14 +104,17 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] }
|
||||
# gpu (the same wgpu eframe / egui-wgpu use)
|
||||
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
bytemuck = "1"
|
||||
pollster = "0.4"
|
||||
pollster = "1"
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
# Shared UI and the complete egui family select reviewed immutable commits below.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
|
||||
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
cpal = "0.16"
|
||||
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
|
||||
@@ -128,17 +136,29 @@ sys-locale = "0.3"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
needless_range_loop = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Panics and backtraces keep file and line; full debug info only made the many test binaries large.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Same settings as `dev`, but with full debug info for interactive debugger sessions: local
|
||||
# variables, parameters and column breakpoints work. Use `--profile dev-full` when debugging;
|
||||
# everything else stays on the fast default. See CONTRIBUTING.md, "Setup".
|
||||
[profile.dev-full]
|
||||
inherits = "dev"
|
||||
debug = true
|
||||
|
||||
# Pixel hot loops: keep debug builds interactive.
|
||||
[profile.dev.package.effectcraft-raster]
|
||||
@@ -210,4 +230,20 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", version = "=0.7.2" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
@@ -12,11 +12,280 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Bundled fonts
|
||||
|
||||
EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from
|
||||
assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision
|
||||
6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack,
|
||||
Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts`
|
||||
and `monochrome_emoji_fonts` features; no font file from that dependency is added to
|
||||
this repository. The complete copyright notices and licence texts required for all of
|
||||
these fonts follow.
|
||||
|
||||
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2
|
||||
(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf).
|
||||
Copyright notices:
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
Copyright 2013 Google Inc. All Rights Reserved.
|
||||
|
||||
The fonts in assets/fonts/ also keep their upstream licence files at
|
||||
assets/fonts/OFL-<family>.txt. The complete licence text follows.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu Font Licence 1.0
|
||||
|
||||
Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice:
|
||||
|
||||
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Hack: MIT License and Bitstream Vera License
|
||||
|
||||
Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
MIT License (egui-icons and emoji-icon-font)
|
||||
|
||||
Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent
|
||||
emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -8,15 +8,19 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png" width="128" height="128" alt="EffectCraft icon: an engraved unicorn on a pink tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">EffectCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.</b>
|
||||
<b>Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A free, open-source compositor in the spirit of After Effects: compositions, layers,
|
||||
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
A free, open-source compositor: compositions, layers,
|
||||
keyframes, 308 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
|
||||
Windows and Linux, and in the browser. Young, moving fast, and already usable.
|
||||
</p>
|
||||
|
||||
@@ -24,6 +28,9 @@
|
||||
<img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-e0368f?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/rust-1.95%2B-b0206c?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_effectcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/effectcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -64,27 +71,29 @@
|
||||
## What EffectCraft is
|
||||
|
||||
EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing
|
||||
people reach for After Effects to do. You build a composition out of layers (solids, shapes,
|
||||
motion designers and VFX artists need. You build a composition out of layers (solids, shapes,
|
||||
text, footage, other compositions), animate their properties with keyframes, stack effects on
|
||||
them and render the result.
|
||||
|
||||
The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project,
|
||||
Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and
|
||||
then to go further in a few places where it matters to us:
|
||||
The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline,
|
||||
Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph,
|
||||
Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in
|
||||
a few places where it matters to us:
|
||||
|
||||
- **Lottie import and export built in**, so animations can go straight to the web and apps
|
||||
without a plugin.
|
||||
- **A project file you can read.** Projects are versioned JSON (`.ecproj`), so they diff cleanly
|
||||
in version control.
|
||||
- **Everything is scriptable.** Every menu item, timeline drag and property edit goes through one
|
||||
command registry, so the same actions are reachable from a command line, a JSON control
|
||||
channel and an MCP server for agents.
|
||||
- **Everything is scriptable.** Menu actions, timeline edits and property changes are exposed as
|
||||
commands. Engine commands are available through the CLI, JSON control channel and MCP;
|
||||
UI-only commands use the frontend dispatcher and can be invoked through the control channel
|
||||
or an attached MCP bridge.
|
||||
- **No FFmpeg.** Video and audio decoding and encoding are pure Rust: FilmCraft's codecs and
|
||||
EffectCraft's own VP9, AV1, HEVC and Opus encoders.
|
||||
|
||||
## Animate
|
||||
|
||||
The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
|
||||
The panels, menus and shortcuts follow standard motion design conventions:
|
||||
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
|
||||
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
|
||||
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
|
||||
@@ -102,7 +111,7 @@ or into the Timeline, between layers and at the time you point to.
|
||||
feather, expansion and vertex editing in the viewer.
|
||||
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
|
||||
Character and Paragraph panels, and text animators with range selectors.
|
||||
- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
|
||||
- **Expressions:** JavaScript with the standard motion expressions 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
|
||||
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
|
||||
@@ -118,9 +127,9 @@ or into the Timeline, between layers and at the time you point to.
|
||||
|
||||
## Effects
|
||||
|
||||
306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
|
||||
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
308 built-in effects across its categories, each with its parameter names, order and
|
||||
defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
|
||||
color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
|
||||
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
|
||||
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
|
||||
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
|
||||
@@ -134,7 +143,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an
|
||||
|
||||
## 3D
|
||||
|
||||
Classic 3D the way After Effects does it: 3D layers with orientation and material options,
|
||||
Classic 3D compositing: 3D layers with orientation and material options,
|
||||
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
|
||||
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
|
||||
tools, and Front, Top, Left and Custom views.
|
||||
@@ -145,9 +154,9 @@ tools, and Front, Top, Left and Custom views.
|
||||
|
||||
## Export
|
||||
|
||||
A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
|
||||
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
|
||||
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
|
||||
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
|
||||
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
|
||||
overflow and render logs. The same queue runs from the command line. Every encoder is pure
|
||||
@@ -165,8 +174,10 @@ cannot express; File ▸ Import ▸ Lottie… opens one as a new composition.
|
||||
|
||||
## Built for agents
|
||||
|
||||
Everything you can do from a menu is a command with an id, and agents can reach every one of
|
||||
them:
|
||||
Engine commands are available through the CLI, JSON control channel and MCP. UI-only commands
|
||||
need the running app's frontend dispatcher: complete menu access requires the app's control
|
||||
channel or an attached MCP bridge. Headless MCP sessions expose supported engine commands,
|
||||
but not UI-only commands or bridge-only `ui_*` tools.
|
||||
|
||||
- **MCP server:** `effectcraft-cli mcp` speaks the Model Context Protocol over stdio, headless
|
||||
or bridged to the running app (`--bridge 9877`). Tools cover commands, the project and property
|
||||
@@ -219,25 +230,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
|
||||
## Where it stands
|
||||
|
||||
EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
|
||||
exists, including all of its effects, but existing is not the same as behaving exactly like After
|
||||
Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
|
||||
what breaks. It isn't yet a replacement for After Effects on client work. In particular:
|
||||
EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
|
||||
exists, including 308 effects, but breadth is not the same as full maturity across every edge
|
||||
case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
|
||||
for production 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.
|
||||
- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
|
||||
project formats and After Effects SDK plug-ins are not supported.
|
||||
- **Fidelity is still being refined.** Automated rendering tests currently focus on small
|
||||
compositions, so please report visual discrepancies or unexpected behaviour.
|
||||
- **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.
|
||||
3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning.
|
||||
|
||||
EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
|
||||
about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
|
||||
has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
|
||||
[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
|
||||
EffectCraft is in **alpha**: about 91% of the features tracked in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the
|
||||
way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md)
|
||||
every known shortfall. 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.
|
||||
|
||||
@@ -245,8 +257,8 @@ send us right now.
|
||||
|
||||
- **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on
|
||||
top and nothing underneath depends on it.
|
||||
- **Clean room.** We work from After Effects' public documentation and how it behaves, never
|
||||
from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules
|
||||
- **Clean room.** We work from public documentation and observable behavior, never
|
||||
from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules
|
||||
are in [AGENTS.md](AGENTS.md).
|
||||
- **Private by default.** No telemetry, and no network access unless you ask for it.
|
||||
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
|
||||
@@ -266,9 +278,13 @@ send us right now.
|
||||
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed. The installer asks where to install EffectCraft
|
||||
(Program Files by default), and later versions install into the same folder. To install silently
|
||||
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
|
||||
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
|
||||
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions
|
||||
install into the same folder. To install silently
|
||||
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
|
||||
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
|
||||
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -368,8 +384,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+30
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written
|
||||
from scratch in Rust. This page is the one-page summary; the evidence is in
|
||||
@@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and
|
||||
| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) |
|
||||
| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) |
|
||||
| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) |
|
||||
@@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b
|
||||
|
||||
## Languages
|
||||
|
||||
UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)).
|
||||
|
||||
| Language | Status | % |
|
||||
|---|---|---:|
|
||||
| English | full | 100% |
|
||||
| Simplified Chinese | partial (menus, part of the panels) | 29% |
|
||||
| Spanish | menus only | 16% |
|
||||
| Simplified Chinese | partial (menus, most of the panels) | 46% |
|
||||
| Spanish | menus only | 29% |
|
||||
| Hindi | none | 0% |
|
||||
| Arabic | none | 0% |
|
||||
| French | menus only | 16% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 26% |
|
||||
| Portuguese (Brazil) | partial (menus, part of the panels) | 34% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | partial (menus, part of the panels) | 27% |
|
||||
| Japanese | partial (menus, most of the panels) | 46% |
|
||||
| German | menus only | 16% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -117,6 +117,25 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain
|
||||
that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it
|
||||
starts off, so existing shapes behave as before (#2489).
|
||||
- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or
|
||||
`layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and
|
||||
rotation of the paths above it by random amounts that change over time, up to the Transform
|
||||
values it holds. The copies of a Repeater above it wiggle independently as far as its
|
||||
Correlation allows; with the Repeater below it, they wiggle together.
|
||||
- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified
|
||||
Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus,
|
||||
tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774:
|
||||
Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese
|
||||
panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is
|
||||
46% with its 307 effect names; the overall share is ≈ 24%.
|
||||
- 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar
|
||||
path into an editable Path 1 with the same geometry, as After Effects does; it used to
|
||||
apply to mask RotoBezier only, so a parametric shape path could not be converted at all
|
||||
and its vertices could not be edited or animated (#932).
|
||||
- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
|
||||
- 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking;
|
||||
strokes and undo history are preserved so restoring the timing allows the span edit again.
|
||||
- 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel
|
||||
@@ -150,11 +169,12 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) |
|
||||
| 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md |
|
||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
||||
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||
@@ -6,16 +6,20 @@
|
||||
//! effectcraft-cli exec <command-id> [--params JSON] run one command
|
||||
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
|
||||
//! effectcraft-cli run --args-file F the same, one argument per line of F
|
||||
//! effectcraft-cli run --commands FILE|- steps {"command", "params"} from FILE or stdin
|
||||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
|
||||
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
|
||||
//! effectcraft-cli render --template T.ectemplate [--values V.json] --out FILE
|
||||
//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
@@ -94,6 +98,11 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
|
||||
run <id> <json> [<id> <json> ...] run several commands in order
|
||||
run --args-file F the same, one argument per line of F (no command-line length limit)
|
||||
run --commands FILE|- steps from FILE, or standard input for -: a JSON array of
|
||||
{\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one
|
||||
such object per line (JSON Lines); at most 64 MiB and 100000 steps.
|
||||
Repeatable; run after the <id> <json> pairs and --args-file
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
@@ -103,9 +112,11 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
|
||||
render/render-frame --template T.ectemplate [--values V.json] render one template instance
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
@@ -144,6 +155,8 @@ const VALUED: &[&str] = &[
|
||||
"--expression",
|
||||
"--depth",
|
||||
"--eval",
|
||||
"--args-file",
|
||||
"--commands",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
@@ -189,6 +202,7 @@ const FLAGS: &[&str] = &[
|
||||
"--small",
|
||||
"--adv3d",
|
||||
"--autosave",
|
||||
"--progress",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
@@ -271,6 +285,9 @@ fn utf8_args(raw: impl IntoIterator<Item = std::ffi::OsString>) -> Result<Vec<St
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Warnings and errors to standard error (`RUST_LOG` overrides), so a GPU that declines
|
||||
// frames or runs out of memory says so instead of silently rendering on the CPU.
|
||||
effectcraft_engine::logging::install_with("warn");
|
||||
let raw = match utf8_args(std::env::args_os().skip(1)) {
|
||||
Ok(raw) => raw,
|
||||
Err(e) => fail_usage(&e),
|
||||
@@ -326,6 +343,87 @@ impl From<String> for Failure {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Most steps one `run --commands` takes.
|
||||
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||
|
||||
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||
/// [`MAX_COMMANDS_BYTES`]. This reader, [`parse_commands`] and [`command_step`] are ported from
|
||||
/// vectorcraft-cli's (storytold/vectorcraft#3630), with a tuple in place of its `Step` enum.
|
||||
fn read_commands(source: &str) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
let read = if source == "-" {
|
||||
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||
} else {
|
||||
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||
};
|
||||
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||
}
|
||||
|
||||
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||
/// {…}}` objects (`"id"` may stand for `"command"`), or one such object per line (JSON Lines, blank
|
||||
/// lines skipped). `first` is the run's index of the first of them, which an error names with the
|
||||
/// entry's place in the text.
|
||||
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<(String, Value)>, String> {
|
||||
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||
// Each entry with where it is in the text: its line for JSON Lines.
|
||||
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||
if text.trim_start().starts_with('[') {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||
if items.len() > MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||
} else {
|
||||
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
if entries.len() == MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||
entries.push((Some(n + 1), v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, (line, v))| {
|
||||
command_step(v).map_err(|e| {
|
||||
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The command id and params of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||
fn command_step(v: Value) -> Result<(String, Value), String> {
|
||||
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"comp.new\", \"params\": {}}".into()) };
|
||||
let id = match (o.remove("command"), o.remove("id")) {
|
||||
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||
(None, None) => return Err("no `command`".into()),
|
||||
_ => return Err("`command` must be a string, a command id".into()),
|
||||
};
|
||||
let params = match o.remove("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p,
|
||||
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||
};
|
||||
if let Some(key) = o.keys().next() {
|
||||
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||
}
|
||||
Ok((id, params))
|
||||
}
|
||||
|
||||
fn usage_err<T>(m: &str) -> Result<T, Failure> {
|
||||
Err(Failure::Usage(m.to_string()))
|
||||
}
|
||||
@@ -510,15 +608,20 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
emit(&with_saved(r, saved), json_out);
|
||||
}
|
||||
"run" => {
|
||||
if args.pos.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs");
|
||||
let mut pairs = args.pos.clone();
|
||||
// Windows caps a command line at 32 767 characters: long batches come from a file.
|
||||
if let Some(f) = args.opt("--args-file") {
|
||||
let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?;
|
||||
pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from));
|
||||
}
|
||||
let pairs = args.pos.clone();
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
let sources = args.opts("--commands");
|
||||
if pairs.is_empty() && sources.is_empty() {
|
||||
return usage_err("run needs <command-id> [<json>] pairs or --commands FILE|-");
|
||||
}
|
||||
let mut steps = vec![];
|
||||
let mut i = 0;
|
||||
while i < pairs.len() {
|
||||
let id = &pairs[i];
|
||||
let id = pairs[i].clone();
|
||||
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
|
||||
Some(p) => {
|
||||
i += 1;
|
||||
@@ -527,9 +630,26 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
None => json!({}),
|
||||
};
|
||||
i += 1;
|
||||
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
|
||||
if let Some(e) = failed_render(id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e}")));
|
||||
steps.push((id, params));
|
||||
}
|
||||
if sources.iter().filter(|s| **s == "-").count() > 1 {
|
||||
return usage_err("--commands - is given twice: standard input is read once");
|
||||
}
|
||||
for &source in &sources {
|
||||
let text = read_commands(source).map_err(Failure::Usage)?;
|
||||
steps.extend(parse_commands(source, &text, steps.len()).map_err(Failure::Usage)?);
|
||||
}
|
||||
if steps.is_empty() {
|
||||
return Err(Failure::Usage(format!("--commands {}: no commands", sources.join(", "))));
|
||||
}
|
||||
let mut b = backend(args, true)?;
|
||||
let mut results = vec![];
|
||||
// The step's index closes the message: a generated list repeats ids, and callers match
|
||||
// the leading `<id>: `.
|
||||
for (n, (id, params)) in steps.into_iter().enumerate() {
|
||||
let r = b.exec(&id, params).map_err(|e| Failure::Error(format!("{id}: {e} (step {n})")))?;
|
||||
if let Some(e) = failed_render(&id, &r) {
|
||||
return Err(Failure::Error(format!("{id}: {e} (step {n})")));
|
||||
}
|
||||
if !json_out {
|
||||
emit(&r, false);
|
||||
@@ -772,8 +892,21 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
let lines = args.flag("--progress") && !tty;
|
||||
let (mut last_line, mut last_done) = (std::time::Instant::now(), u64::MAX);
|
||||
while s.is_rendering() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if lines
|
||||
&& last_line.elapsed() >= std::time::Duration::from_millis(250)
|
||||
&& let Some(p) = s.render_progress()
|
||||
&& p.done != last_done
|
||||
{
|
||||
last_line = std::time::Instant::now();
|
||||
last_done = p.done;
|
||||
let left = p.remaining.map_or_else(|| "-".to_string(), |r| format!("{r:.1}"));
|
||||
note!("progress {} {} {:.1} {left}", p.done, p.total, p.item_elapsed);
|
||||
}
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
let _ = write!(
|
||||
@@ -1163,7 +1296,7 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
comp.layers = layers.into();
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::io::Read;
|
||||
|
||||
use effectcraft_engine::{
|
||||
EngineError, Session,
|
||||
project::{ItemId, LayerId},
|
||||
project::{ItemId, LayerId, essential::EgKind},
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -92,7 +92,7 @@ fn preflight(s: &Session, path: &str, values: &Map<String, Value>) -> Result<(),
|
||||
project.fix_next_id();
|
||||
let mut check = Session::new();
|
||||
check.project = project.into();
|
||||
Ok(instantiate(&mut check, source, values))
|
||||
Ok(instantiate(&mut check, source, values, false))
|
||||
})
|
||||
.map_err(|e| Failure::Error(e.to_string()))?
|
||||
}
|
||||
@@ -105,10 +105,27 @@ pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> {
|
||||
}
|
||||
let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?;
|
||||
let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?;
|
||||
instantiate(s, source, &values)
|
||||
instantiate(s, source, &values, true)
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Result<(), Failure> {
|
||||
/// The file a Media Replacement control is given: a path, or `{path|file|media: path}`.
|
||||
fn media_path(value: &Value) -> Option<&str> {
|
||||
match value {
|
||||
Value::String(path) => Some(path.as_str()),
|
||||
Value::Object(m) => ["path", "file", "media"].iter().find_map(|k| m.get(*k)).and_then(Value::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the template's control of that name (looked up as `essential.set` does) is a Media Replacement.
|
||||
fn is_media_control(s: &Session, source: u64, name: &str) -> bool {
|
||||
let Some(eg) = s.project.comp(ItemId(source)).and_then(|c| c.essential.as_ref()) else { return false };
|
||||
let all = eg.flat();
|
||||
let control = all.iter().find(|c| c.name == name).or_else(|| all.iter().find(|c| c.name.eq_ignore_ascii_case(name)));
|
||||
control.is_some_and(|c| matches!(c.kind, EgKind::Media { .. }))
|
||||
}
|
||||
|
||||
fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import_media: bool) -> Result<(), Failure> {
|
||||
// Duplicate and empty the source with commands: this preserves exact rational frame rate,
|
||||
// Tick duration, background, work area and renderer settings without round-tripping floats.
|
||||
let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?;
|
||||
@@ -133,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
|
||||
}
|
||||
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
|
||||
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
|
||||
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
|
||||
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the
|
||||
// wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs.
|
||||
s.edit("Template Duration", None, |project, _| {
|
||||
let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone();
|
||||
project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name;
|
||||
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
|
||||
let duration = comp.duration;
|
||||
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
|
||||
@@ -145,6 +165,25 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>) -> Res
|
||||
// Let the wrapper carry the source composition's authored motion blur.
|
||||
execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?;
|
||||
for (control, value) in values {
|
||||
if is_media_control(s, source, control) {
|
||||
let path = media_path(value).ok_or_else(|| Failure::Error(format!("control `{control}` is a media control: give it a file path")))?;
|
||||
// The preflight session cannot import files; the real run reports a bad path.
|
||||
if !import_media {
|
||||
continue;
|
||||
}
|
||||
let imported = execute(s, "file.import", json!({"paths": [path]}))?;
|
||||
if let Some(err) = imported.get("errors").and_then(Value::as_array).and_then(|e| e.first()) {
|
||||
return Err(Failure::Error(format!("control `{control}`: cannot import {path}: {err}")));
|
||||
}
|
||||
let item = imported
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| Failure::Error(format!("control `{control}`: {path} did not import as footage")))?;
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "item": item}))?;
|
||||
continue;
|
||||
}
|
||||
execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
Command::new(crate::binary())
|
||||
}
|
||||
|
||||
/// Run with `--json`; returns (exit code, parsed stdout).
|
||||
@@ -110,6 +110,101 @@ fn exec_set_get_with_saved_project() {
|
||||
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
|
||||
}
|
||||
|
||||
/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer
|
||||
/// than a Windows command line (32 767 characters, os error 206) still runs.
|
||||
#[test]
|
||||
fn run_reads_arguments_from_a_file() {
|
||||
let proj = tmp("args-file.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()];
|
||||
for i in 0..400 {
|
||||
lines.push("layer.newSolid".into());
|
||||
lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string());
|
||||
lines.push(String::new());
|
||||
}
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > 32_767);
|
||||
let file = tmp("args-file.txt");
|
||||
std::fs::write(&file, text).unwrap();
|
||||
let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]);
|
||||
assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}");
|
||||
let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt"));
|
||||
}
|
||||
|
||||
/// Run with `stdin` piped in; returns (exit code, stdout, stderr).
|
||||
fn run_with_stdin(args: &[&str], stdin: &str) -> (i32, String, String) {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(stdin.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
(out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
|
||||
/// `run --commands FILE|-` (#2251) reads `{"command", "params"}` steps from a JSON array or JSON
|
||||
/// Lines, from a file or standard input, and runs them as the same positional pairs would: after
|
||||
/// the pairs, so a generated list can follow a hand-written prefix. One command whose params are
|
||||
/// over Linux's 128 KiB per-argument limit goes through.
|
||||
#[test]
|
||||
fn run_reads_commands_from_a_file_or_stdin() {
|
||||
let steps = [
|
||||
("comp.new", json!({"name": "A", "width": 64, "height": 64})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "One"})),
|
||||
("layer.newSolid", json!({"width": 8, "height": 8, "name": "Two"})),
|
||||
("project.summary", json!({})),
|
||||
];
|
||||
let pairs: Vec<String> = steps.iter().flat_map(|(id, p)| [id.to_string(), p.to_string()]).collect();
|
||||
let pairs: Vec<&str> = pairs.iter().map(String::as_str).collect();
|
||||
let expected = ok_json(&[&["run", "--empty"][..], &pairs[..]].concat());
|
||||
let layers = |v: &Value| v[3]["result"]["items"][0]["layers"].as_u64();
|
||||
assert_eq!(layers(&expected), Some(2), "{expected}");
|
||||
|
||||
let objects: Vec<Value> = steps.iter().map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let array = tmp("commands.json");
|
||||
std::fs::write(&array, serde_json::to_string_pretty(&objects).unwrap()).unwrap();
|
||||
assert_eq!(ok_json(&["run", "--commands", array.to_str().unwrap(), "--empty"]), expected);
|
||||
|
||||
let lines: String = objects.iter().map(|o| format!("{o}\n\n")).collect();
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], &lines);
|
||||
assert_eq!(code, 0, "{err}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&out).unwrap(), expected);
|
||||
|
||||
// Positional pairs first, then the file.
|
||||
let rest = tmp("commands-rest.jsonl");
|
||||
std::fs::write(&rest, objects[1..].iter().map(|o| format!("{o}\n")).collect::<String>()).unwrap();
|
||||
assert_eq!(ok_json(&[&["run", "--empty", "--commands", rest.to_str().unwrap()][..], &pairs[..2]].concat()), expected);
|
||||
|
||||
let name = "n".repeat(140 * 1024);
|
||||
let big = tmp("commands-big.jsonl");
|
||||
std::fs::write(&big, format!("{}\n{}\n", json!({"command": "comp.new", "params": {"name": name}}), json!({"command": "project.summary"}))).unwrap();
|
||||
let v = ok_json(&["run", "--commands", big.to_str().unwrap(), "--empty"]);
|
||||
assert_eq!(v[1]["result"]["items"][0]["name"].as_str().map(str::len), Some(name.len()));
|
||||
|
||||
for (input, args, error) in [
|
||||
("", &["run", "--commands", "-", "--empty"][..], "--commands -: no commands"),
|
||||
(
|
||||
"{\"command\":\"project.summary\"}\n{\"command\":\"x\",\"param\":{}}\n",
|
||||
&["run", "project.summary", "{}", "--commands", "-", "--empty"],
|
||||
"step 2 (line 2): unknown key `param`",
|
||||
),
|
||||
("[{\"command\":\"project.summary\"}, {\"params\":{}}]", &["run", "--commands", "-", "--empty"], "step 1 (entry 2): no `command`"),
|
||||
("{}", &["run", "--commands", "-", "--commands", "-", "--empty"], "standard input is read once"),
|
||||
] {
|
||||
let (code, out, err) = run_with_stdin(args, input);
|
||||
assert_eq!(code, 2, "{input:?}: {err}");
|
||||
assert!(err.contains(error), "{input:?}: {err}");
|
||||
assert!(out.is_empty(), "{input:?}: nothing ran");
|
||||
}
|
||||
// An empty list after positional pairs is no error: there is still something to run.
|
||||
let (code, _, err) = run_with_stdin(&["run", "project.summary", "--commands", "-", "--empty"], "");
|
||||
assert_eq!(code, 0, "{err}");
|
||||
// A step that fails when it runs is named by its index too.
|
||||
let (code, out, err) = run_with_stdin(&["run", "--commands", "-", "--empty", "--json"], "{\"command\":\"project.summary\"}\n{\"command\":\"no.such\"}\n");
|
||||
assert_eq!(code, 1, "{err}");
|
||||
let e = serde_json::from_str::<Value>(&out).unwrap()["error"].as_str().map(String::from).unwrap_or_default();
|
||||
assert!(e.starts_with("no.such: ") && e.ends_with(" (step 1)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_frame_writes_png() {
|
||||
let out = tmp("f.png");
|
||||
@@ -593,3 +688,77 @@ solid.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraph
|
||||
assert_eq!(reply["error"], "command `renderQueue.add` is not available right now: select or open a composition", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` that matches nothing is a clean `--gpu` error listing the adapters
|
||||
/// found (wgpu's own helper panics). Read once per process, hence a subprocess.
|
||||
#[test]
|
||||
fn gpu_adapter_name_mismatch_is_a_clean_error() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("nomatch.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", "NoSuchAdapter-xyz")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_str(stdout.trim()).unwrap();
|
||||
let e = v["error"].as_str().unwrap();
|
||||
assert!(e.contains("WGPU_ADAPTER_NAME=\"NoSuchAdapter-xyz\""), "{e}");
|
||||
assert!(e.contains("available: [") || e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
|
||||
/// An exported-but-empty `WGPU_ADAPTER_NAME` behaves like unset: it never produces the
|
||||
/// adapter-name error (whether a GPU exists on this machine is not asserted).
|
||||
#[test]
|
||||
fn gpu_adapter_name_empty_is_unset() {
|
||||
let out = bin()
|
||||
.args(["render-frame", "--gpu", "--max-side", "64", "--out", tmp("emptyname.png").to_str().unwrap(), "--json"])
|
||||
.env("WGPU_ADAPTER_NAME", " ")
|
||||
.output()
|
||||
.unwrap();
|
||||
let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!all.contains("WGPU_ADAPTER_NAME"), "{all}");
|
||||
}
|
||||
|
||||
/// Renders two seconds of the demo at quarter resolution (long enough for several progress
|
||||
/// updates) and returns stderr, which `output()` reads through a pipe: not a terminal.
|
||||
fn render_demo_stderr(name: &str, extra: &[&str]) -> String {
|
||||
let out = tmp(name);
|
||||
let mut args = vec!["render", "--demo", "--format", "gif", "--start", "0", "--end", "2", "--resolution", "quarter", "--out", out.to_str().unwrap()];
|
||||
args.extend_from_slice(extra);
|
||||
let o = bin().args(&args).output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
assert!(o.status.success(), "{args:?}: {stderr}");
|
||||
let _ = std::fs::remove_file(out);
|
||||
stderr
|
||||
}
|
||||
|
||||
/// `render --progress` with a piped stderr reports the render's frames as
|
||||
/// `progress <frames done> <frames total> <seconds elapsed> <seconds left|->` lines, one per
|
||||
/// change (#1778).
|
||||
#[test]
|
||||
fn render_progress_reports_frames_on_a_piped_stderr() {
|
||||
let stderr = render_demo_stderr("progress.gif", &["--progress"]);
|
||||
let mut last_done = None;
|
||||
let mut totals = vec![];
|
||||
for line in stderr.lines().filter(|l| l.starts_with("progress")) {
|
||||
let fields: Vec<&str> = line.split(' ').collect();
|
||||
assert_eq!(fields.len(), 5, "{line}");
|
||||
let done: u64 = fields[1].parse().unwrap_or_else(|e| panic!("{line}: frames done: {e}"));
|
||||
let total: u64 = fields[2].parse().unwrap_or_else(|e| panic!("{line}: frames total: {e}"));
|
||||
let elapsed: f64 = fields[3].parse().unwrap_or_else(|e| panic!("{line}: seconds elapsed: {e}"));
|
||||
assert!(fields[4] == "-" || fields[4].parse::<f64>().is_ok_and(|left| left >= 0.0), "{line}: seconds left");
|
||||
assert!(done <= total && total > 0 && elapsed >= 0.0, "{line}");
|
||||
assert!(last_done.is_none_or(|last| done > last), "one line per change, and frames only go up: {stderr}");
|
||||
last_done = Some(done);
|
||||
totals.push(total);
|
||||
}
|
||||
assert!(!totals.is_empty(), "no progress line on a piped stderr: {stderr}");
|
||||
assert!(totals.iter().all(|&t| t == totals[0]), "one render, one total: {stderr}");
|
||||
}
|
||||
|
||||
/// Without `--progress`, the same render writes no progress lines to a piped stderr (#1778).
|
||||
#[test]
|
||||
fn render_without_progress_writes_no_progress_lines() {
|
||||
let stderr = render_demo_stderr("no-progress.gif", &[]);
|
||||
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod cli;
|
||||
mod qa_template;
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_effectcraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft-cli").into())
|
||||
}
|
||||
+145
-17
@@ -9,7 +9,7 @@ use std::process::Command;
|
||||
use serde_json::Value;
|
||||
|
||||
fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap();
|
||||
let out = Command::new(crate::binary()).current_dir(dir).args(args).arg("--json").output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr)));
|
||||
(out.status.code().unwrap_or(-1), v)
|
||||
@@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
|
||||
|
||||
invalid_template_values_fail_cleanly(&dir);
|
||||
template_usage_errors(&dir);
|
||||
rational_template_keeps_exact_rate(&dir, &original_template);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn invalid_template_values_fail_cleanly(dir: &PathBuf) {
|
||||
// Invalid values fail before the queue creates an output directory, or frame rendering
|
||||
// overwrites an existing output. A JSON object and existing control names are required.
|
||||
for (name, values, expected) in [
|
||||
@@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
std::fs::write(dir.join(&values_file), values).unwrap();
|
||||
let output_dir = dir.join(format!("failed-{name}"));
|
||||
let out = output_dir.join("frame_[#####].png");
|
||||
let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
|
||||
let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{name}: {error}");
|
||||
assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
|
||||
assert!(!output_dir.exists(), "{name}: invalid input created render output");
|
||||
std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
|
||||
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
|
||||
assert_eq!(code, 1, "{name}: {error}");
|
||||
assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
|
||||
}
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
|
||||
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
|
||||
assert_eq!(code, 1, "{error}");
|
||||
assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
|
||||
assert!(!dir.join("missing.png").exists());
|
||||
std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
|
||||
let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
|
||||
let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
|
||||
assert_eq!(code, 1, "{error}");
|
||||
assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
|
||||
assert!(!dir.join("large.png").exists());
|
||||
}
|
||||
|
||||
fn template_usage_errors(dir: &PathBuf) {
|
||||
for args in [
|
||||
vec!["render-frame", "--values", "values.json"],
|
||||
vec!["info", "--template", "lt.ectemplate"],
|
||||
@@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
vec!["render", "--template", "lt.ectemplate", "--queue"],
|
||||
vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) {
|
||||
// File-loaded templates can use exact rational rates that comp.new's float parser rounds.
|
||||
// Preserve that rate, Tick duration, work area, background and locked source layers.
|
||||
let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
|
||||
@@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
comp.background = [0.08, 0.16, 0.24];
|
||||
comp.layers.first_mut().unwrap().switches.locked = true;
|
||||
std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
|
||||
ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
|
||||
let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
|
||||
let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
|
||||
let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
|
||||
assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
|
||||
assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
|
||||
let output = dir.join("rational-work-area/frame_[#####].png");
|
||||
ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
|
||||
ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
|
||||
let count = std::fs::read_dir(dir.join("rational-work-area"))
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
@@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() {
|
||||
assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
|
||||
assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
|
||||
assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn template_output_alias_fixture(case: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -251,7 +262,7 @@ fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) -> Value {
|
||||
let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_before = std::fs::read(dir.join("values.json")).unwrap();
|
||||
// Read the inputs before interpreting the subprocess status or error text.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
let result = Command::new(crate::binary())
|
||||
.current_dir(dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"])
|
||||
.output()
|
||||
@@ -527,7 +538,7 @@ fn template_unknown_control_does_not_replace_extracted_media() {
|
||||
let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap();
|
||||
|
||||
// Capture every file before interpreting status/JSON, so a failed command cannot hide a write.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
let result = Command::new(crate::binary())
|
||||
.current_dir(&dir)
|
||||
.args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"])
|
||||
.output()
|
||||
@@ -623,11 +634,8 @@ fn template_missing_output_does_not_replace_extracted_media() {
|
||||
assert_ne!(sentinel_before, source_before);
|
||||
|
||||
// Observe writes before interpreting the actual CLI usage exit.
|
||||
let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
|
||||
.current_dir(&dir)
|
||||
.args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let result =
|
||||
Command::new(crate::binary()).current_dir(&dir).args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]).output().unwrap();
|
||||
let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap();
|
||||
let values_after = std::fs::read(dir.join("values.json")).unwrap();
|
||||
let source_after = std::fs::read(dir.join("source.png")).unwrap();
|
||||
@@ -689,3 +697,123 @@ fn template_frame_output_cannot_alias_its_values_through_a_hard_link() {
|
||||
assert_eq!(reply["error"], "--out hard-linked-values.png would overwrite --values values.json", "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A template whose `Clip` control is a Media Replacement showing `source.png` (blue), with
|
||||
/// `other.png` (red) beside it to replace it with, exported as `lt.ectemplate` in a fresh `dir`.
|
||||
fn media_template(case: &str) -> PathBuf {
|
||||
let temp_root = std::env::temp_dir().canonicalize().unwrap();
|
||||
let dir = temp_root.join(format!("ec-qa-template-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("build.jsx"), BUILD).unwrap();
|
||||
ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 20, 40, 255])).save(dir.join("other.png")).unwrap();
|
||||
let imported = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]);
|
||||
let item = imported["result"]["items"][0].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string();
|
||||
let added = ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]);
|
||||
let layer = added["result"]["layer"].as_u64().unwrap();
|
||||
let params = serde_json::json!({"comp": "Lower Third", "layer": layer, "name": "Clip"}).to_string();
|
||||
ok(&dir, &["exec", "essential.addMedia", ¶ms, "t.ecproj", "--save"]);
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Renders the template's frame with `values` and checks that the control shows `other.png`.
|
||||
fn assert_renders_the_replacement(dir: &PathBuf, values: &str) {
|
||||
std::fs::write(dir.join("values.json"), values).unwrap();
|
||||
ok(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "out.png"]);
|
||||
let px = image::open(dir.join("out.png")).unwrap().to_rgba8().get_pixel(8, 8).0;
|
||||
assert!(
|
||||
(px[0] as i32 - 200).abs() <= 2 && (px[1] as i32 - 20).abs() <= 2 && (px[2] as i32 - 40).abs() <= 2,
|
||||
"{values}: replacement media rendered: {px:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_values_fill_a_media_replacement_control_with_a_file_path() {
|
||||
let dir = media_template("media-control");
|
||||
assert_renders_the_replacement(&dir, r#"{"Clip":"other.png"}"#);
|
||||
|
||||
// A control that is not given a path is rejected with a clear message.
|
||||
std::fs::write(dir.join("bad.json"), r#"{"Clip":5}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "bad.json", "--time", "1", "--out", "bad.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("media control"), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The object forms `{"path"|"file"|"media": …}` take a file path too, and the control is found
|
||||
/// by name the way `essential.set` finds it: exactly, then ignoring case (#1787).
|
||||
#[test]
|
||||
fn template_media_control_takes_a_path_object_and_a_name_in_any_case() {
|
||||
let dir = media_template("media-forms");
|
||||
for values in [
|
||||
r#"{"Clip":{"path":"other.png"}}"#,
|
||||
r#"{"Clip":{"file":"other.png"}}"#,
|
||||
r#"{"Clip":{"media":"other.png"}}"#,
|
||||
r#"{"clip":"other.png"}"#,
|
||||
r#"{"CLIP":{"path":"other.png"}}"#,
|
||||
] {
|
||||
let _ = std::fs::remove_file(dir.join("out.png"));
|
||||
assert_renders_the_replacement(&dir, values);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A media path that cannot be imported fails the render with an error naming the control and
|
||||
/// the path, and writes no frame (#1787).
|
||||
#[test]
|
||||
fn template_media_control_reports_a_path_that_cannot_be_imported() {
|
||||
let dir = media_template("media-missing");
|
||||
std::fs::write(dir.join("missing.json"), r#"{"Clip":"missing.png"}"#).unwrap();
|
||||
let (code, reply) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing.json", "--time", "1", "--out", "missing-out.png"]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
let error = reply["error"].as_str().unwrap();
|
||||
assert!(error.contains("control `Clip`: cannot import missing.png"), "{reply}");
|
||||
assert!(!dir.join("missing-out.png").exists(), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Renders with `--template` name their outputs after the template's composition (#1937), not
|
||||
/// the numbered name a duplicate would get. The name ends in a number and differs from the
|
||||
/// archive's file name; an expression that looks the composition up by that name still finds it.
|
||||
#[test]
|
||||
fn template_render_outputs_keep_the_template_composition_name() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace(
|
||||
"var fill =",
|
||||
"bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =",
|
||||
);
|
||||
std::fs::write(dir.join("build.jsx"), build).unwrap();
|
||||
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
|
||||
assert_eq!(built["output"], "controls: 3", "{built}");
|
||||
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]);
|
||||
let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap();
|
||||
std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap();
|
||||
|
||||
for (case, values) in [("defaults", None), ("values", Some("values.json"))] {
|
||||
let out = dir.join(case).join("[compName]_[#####].png");
|
||||
let mut args =
|
||||
vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"];
|
||||
if let Some(values) = values {
|
||||
args.extend(["--values", values]);
|
||||
}
|
||||
let rendered = ok(&dir, &args);
|
||||
let rendered = rendered["rendered"].as_array().unwrap();
|
||||
assert_eq!(rendered.len(), 1, "{case}: {rendered:?}");
|
||||
assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}");
|
||||
let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(frames.len(), 1, "{case}: {frames:?}");
|
||||
assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}");
|
||||
// The template's own two layers place the bar; the one-layer wrapper would move it left.
|
||||
let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8();
|
||||
let bar = frame.get_pixel(300, 75).0;
|
||||
assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}");
|
||||
}
|
||||
assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
"CssStyleDeclaration",
|
||||
"Document",
|
||||
"Element",
|
||||
@@ -50,6 +52,13 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"ReadableStream",
|
||||
"ReadableStreamDefaultReader",
|
||||
"Request",
|
||||
"RequestCache",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
//
|
||||
// Plain functions only: the Rust side owns the logic.
|
||||
|
||||
// ------------------------------------------------------------------ shortcuts
|
||||
|
||||
/// Keep Duplicate on the app canvas instead of also opening the browser's bookmarks.
|
||||
/// The physical D key counts on layouts where it types no ASCII letter (e.g. Cyrillic), because
|
||||
/// eframe falls back to the key code there; it does not on Dvorak, where it types E.
|
||||
/// eframe still receives the key; other chords and HTML inputs keep their browser behavior.
|
||||
export function installCanvasShortcuts(canvas) {
|
||||
canvas.addEventListener("keydown", (event) => {
|
||||
if (event.target === canvas && (event.ctrlKey || event.metaKey)
|
||||
&& !event.shiftKey && !event.altKey && !event.isComposing
|
||||
&& (event.key.toLowerCase() === "d"
|
||||
|| (event.code === "KeyD" && !/^[a-z]$/i.test(event.key)))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ storage
|
||||
|
||||
const DIR = "effectcraft";
|
||||
@@ -19,7 +36,7 @@ const enc = (k) => encodeURIComponent(k);
|
||||
const dec = (n) => decodeURIComponent(n);
|
||||
|
||||
async function opfs() {
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
|
||||
if (!navigator.storage?.getDirectory) throw new Error("no OPFS");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle(DIR, { create: true });
|
||||
// Probe a write: some browsers expose OPFS but only write it from workers.
|
||||
@@ -57,6 +74,26 @@ function req(r) {
|
||||
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
||||
}
|
||||
|
||||
// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has
|
||||
// committed. A request's success doesn't mean the write is stored: the transaction can still
|
||||
// abort afterwards (quota at commit, the browser closing), rolling it back, and a write
|
||||
// reported as saved then is never retried (#1242). A failed request aborts the transaction
|
||||
// with its error; an explicit abort leaves no error, hence the AbortError.
|
||||
function write(db, store, op) {
|
||||
return new Promise((res, rej) => {
|
||||
let tx = null;
|
||||
try {
|
||||
tx = db.transaction(store, "readwrite");
|
||||
tx.oncomplete = () => res();
|
||||
tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError"));
|
||||
op(tx.objectStore(store));
|
||||
} catch (e) {
|
||||
try { tx?.abort(); } catch {}
|
||||
rej(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function idb() {
|
||||
if (!self.indexedDB) throw new Error("no IndexedDB");
|
||||
const open = indexedDB.open(DB, 1);
|
||||
@@ -70,10 +107,10 @@ async function idb() {
|
||||
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
|
||||
},
|
||||
async put(key, data, modified) {
|
||||
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
|
||||
await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified }));
|
||||
},
|
||||
async del(key) {
|
||||
await req(tx("readwrite").delete(key));
|
||||
await write(db, STORE, (s) => s.delete(key));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,7 +133,7 @@ export async function storeOpen(prefer) {
|
||||
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
|
||||
}
|
||||
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
|
||||
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
|
||||
try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {}
|
||||
return backend.name;
|
||||
}
|
||||
|
||||
@@ -108,7 +145,7 @@ export async function storeDelete(key) { return backend.del(key); }
|
||||
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
|
||||
export async function storePersist() {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
|
||||
if (navigator.storage?.persist) return await navigator.storage.persist();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
@@ -207,7 +244,7 @@ export async function audioStart(workletUrl) {
|
||||
}
|
||||
if (gen !== generation) return;
|
||||
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
|
||||
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
|
||||
node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; };
|
||||
kind = "worklet";
|
||||
} catch (e) {
|
||||
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
|
||||
@@ -298,11 +335,11 @@ function spawn(base) {
|
||||
const w = { worker, files: new Map(), ready: null, gpu: null };
|
||||
w.ready = new Promise((res, rej) => {
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data && e.data.type === "gpu") {
|
||||
if (e.data?.type === "gpu") {
|
||||
w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" };
|
||||
jobGpu = w.gpu;
|
||||
}
|
||||
if (e.data && e.data.type === "ready") res();
|
||||
if (e.data?.type === "ready") res();
|
||||
};
|
||||
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
|
||||
});
|
||||
@@ -315,7 +352,15 @@ function spawn(base) {
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
|
||||
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
let w;
|
||||
try {
|
||||
w = idle.pop() ?? spawn(base);
|
||||
} catch (e) {
|
||||
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
|
||||
// fails like one whose worker didn't start.
|
||||
setTimeout(() => onMessage("error", String(e?.message || e), null, null));
|
||||
return;
|
||||
}
|
||||
running.set(id, w);
|
||||
w.ready.then(() => {
|
||||
w.worker.onmessage = (e) => {
|
||||
@@ -380,10 +425,20 @@ const frameWorkers = [];
|
||||
/// GPU effects on it.
|
||||
export function frameWorkerStart(base, onMessage, gpu) {
|
||||
const index = frameWorkers.length;
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
} catch (e) {
|
||||
// The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The
|
||||
// slot stays, and the failure is reported like a worker that died, after the caller has
|
||||
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
|
||||
frameWorkers.push(null);
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed")));
|
||||
return index;
|
||||
}
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
|
||||
@@ -409,7 +464,7 @@ const cacheDirHandles = {};
|
||||
|
||||
async function cacheDir(kind = "frames") {
|
||||
if (cacheDirHandles[kind]) return cacheDirHandles[kind];
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
|
||||
if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System");
|
||||
let d = await navigator.storage.getDirectory();
|
||||
for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true });
|
||||
cacheDirHandles[kind] = d;
|
||||
@@ -456,8 +511,12 @@ export async function cacheRead(name) {
|
||||
/// resolves with its size.
|
||||
export async function cacheWrite(fullName, bytes) {
|
||||
const [dir, name] = await entryDir(fullName);
|
||||
const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
|
||||
const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
|
||||
const canMove =
|
||||
typeof FileSystemHandle !== "undefined" &&
|
||||
"move" in FileSystemHandle.prototype &&
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function";
|
||||
const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
|
||||
const fh = await dir.getFileHandle(tmp, { create: true });
|
||||
if (fh.createSyncAccessHandle) {
|
||||
const h = await fh.createSyncAccessHandle();
|
||||
@@ -502,7 +561,7 @@ export async function cacheClear() {
|
||||
|
||||
/// Whether this thread has WebGPU (`navigator.gpu`).
|
||||
export function hasWebGpu() {
|
||||
return !!(self.navigator && self.navigator.gpu);
|
||||
return !!self.navigator?.gpu;
|
||||
}
|
||||
|
||||
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
|
||||
@@ -550,7 +609,7 @@ async function openFolder(handle, remember) {
|
||||
if (remember) {
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
|
||||
await write(db, "folders", (s) => s.put({ name, handle }));
|
||||
} catch (e) { console.info("media browser: cannot remember the folder", e); }
|
||||
}
|
||||
return { name, entries };
|
||||
@@ -602,7 +661,7 @@ export async function browseForget(name) {
|
||||
folders.delete(name);
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
|
||||
await write(db, "folders", (s) => s.delete(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -610,7 +669,7 @@ export async function browseForget(name) {
|
||||
export async function browseRead(path) {
|
||||
const rel = path.replace(/^\/Folders\//, "");
|
||||
const folder = folders.get(rel.split("/")[0]);
|
||||
const h = folder && folder.files.get(rel);
|
||||
const h = folder?.files.get(rel);
|
||||
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
|
||||
return new Uint8Array(await (await h.getFile()).arrayBuffer());
|
||||
}
|
||||
@@ -664,7 +723,7 @@ export function showDeviceLost(message, kept) {
|
||||
self.effectcraft
|
||||
.execute("file.save", {})
|
||||
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
|
||||
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
|
||||
.catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`));
|
||||
const reload = document.createElement("button");
|
||||
reload.textContent = "Reload";
|
||||
reload.onclick = async () => {
|
||||
@@ -672,7 +731,7 @@ export function showDeviceLost(message, kept) {
|
||||
try {
|
||||
await self.effectcraft.flush();
|
||||
} catch (e) {
|
||||
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
reload.textContent = "Reload Anyway";
|
||||
reload.disabled = false;
|
||||
reload.onclick = () => location.reload();
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
|
||||
use crate::startup::Startup;
|
||||
use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -15,6 +16,8 @@ use wasm_bindgen::prelude::*;
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = showDeviceLost)]
|
||||
fn show_device_lost(message: &str, kept: bool);
|
||||
#[wasm_bindgen(js_name = installCanvasShortcuts)]
|
||||
fn install_canvas_shortcuts(canvas: &web_sys::HtmlCanvasElement);
|
||||
}
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
@@ -81,32 +84,53 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
/// Keep the session snapshot (off with `?empty`).
|
||||
/// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until
|
||||
/// the user's own work begins, see `demo_start`).
|
||||
pub persist: bool,
|
||||
/// Started on the demo by the config: the snapshot stays off, so the demo never replaces the
|
||||
/// last visit's project, until the user opens a project from a file or edits the demo.
|
||||
pub demo_start: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
/// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously
|
||||
/// through `open_or_import`, then run the shared app's input hook.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// Files dropped on the page are input: read them here, where the browser's drop event is
|
||||
// still the current pass's, not from a later (possibly logic-only) tick.
|
||||
//
|
||||
// The list is *taken* out of the raw input: a hidden tab runs `logic` and eframe appends
|
||||
// the same prepared raw input back for the next pass, so leaving the files there would
|
||||
// import them again on every hidden logic tick.
|
||||
let dropped_files = std::mem::take(&mut raw_input.dropped_files);
|
||||
let dropped: Vec<web_sys::File> = dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect();
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
/// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render
|
||||
/// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close
|
||||
/// the IME pass.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.tick(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// The user's own work after a demo start is worth keeping too: turn the snapshot on.
|
||||
if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) {
|
||||
self.demo_start = false;
|
||||
self.persist = true;
|
||||
}
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
|
||||
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
|
||||
persist::snapshot(&self.app.session, self.app.now(), 1.0);
|
||||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
api::set_info("frameWorkers", frames::stats());
|
||||
@@ -114,6 +138,7 @@ impl eframe::App for WebApp {
|
||||
let d = &self.app.session.prefs.disk;
|
||||
diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota());
|
||||
api::set_info("diskCache", diskcache::stats());
|
||||
self.app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -184,13 +209,21 @@ fn install_hooks(app: &mut EffectcraftApp) {
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// Match System follows `?lang=` in the address, then the browser's languages; a language
|
||||
// chosen in Settings still wins.
|
||||
let navigator = web_sys::window().map(|w| w.navigator());
|
||||
let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::<Vec<_>>());
|
||||
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
install_canvas_shortcuts(&canvas);
|
||||
let empty = query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
// the last session come back.
|
||||
let t0 = js_sys::Date::now();
|
||||
// Monotonic (performance.now) rather than the wall clock: a system-time jump must not turn
|
||||
// the storage-load report negative or huge.
|
||||
let t0 = web_time::Instant::now();
|
||||
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -198,8 +231,13 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
0
|
||||
}
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
|
||||
audio::install_unlock();
|
||||
// `./effectcraft-config.json`: what to open on load.
|
||||
// `?empty` and `?demo` decide by themselves, so they skip the request.
|
||||
let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await };
|
||||
// An explicit `"demo"` start leaves the saved session alone until the user opens or edits something.
|
||||
let keep_session = !empty && startup.keeps_session();
|
||||
browse::restore();
|
||||
diskcache::init();
|
||||
storage::refresh();
|
||||
@@ -209,6 +247,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
// The page owns this shared presentation device; worker devices retain their
|
||||
// separate owned-device handlers. No browser error-scope future is blocked on.
|
||||
@@ -224,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
// The canvas can't present any more (#225): the page says so over it and
|
||||
// offers a reload, which brings the session back when browser storage keeps it.
|
||||
let lost = gpu_failures.clone();
|
||||
let kept = !empty && persist::backend() != "memory";
|
||||
let kept = keep_session && persist::backend() != "memory";
|
||||
rs.device.set_device_lost_callback(move |reason, message| {
|
||||
let message = format!("GPU device lost ({reason:?}): {message}");
|
||||
lost.report(&message, true);
|
||||
@@ -233,11 +272,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
// Scripts in Scripts/Startup run before a project opens.
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
|
||||
// project (the demo on a first visit).
|
||||
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
|
||||
if !restored && !empty {
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
|
||||
// `startup` says (default: the last visit's project, the demo on a first visit).
|
||||
let demo = query_flag("demo");
|
||||
let restored = !empty && !demo && startup.restores() && persist::restore(&mut session);
|
||||
if !restored && !empty && (demo || startup.demo_when_nothing_restored()) {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
@@ -257,12 +299,13 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
|
||||
}
|
||||
app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app, persist: !empty }))
|
||||
Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -52,7 +52,7 @@ struct State {
|
||||
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The web app's Media Browser.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::startup::{self, Startup};
|
||||
|
||||
/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up.
|
||||
const TIMEOUT_MS: i32 = 5_000;
|
||||
|
||||
thread_local! {
|
||||
/// `None`: not fetched yet; `Some(None)`: no usable file.
|
||||
static FILE: RefCell<Option<Option<Vec<u8>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request
|
||||
/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read).
|
||||
async fn fetch() -> Option<Vec<u8>> {
|
||||
let win = web_sys::window()?;
|
||||
let ctl = web_sys::AbortController::new().ok()?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
// Always ask the server: the file is the operator's switch, never a cached copy.
|
||||
init.set_cache(web_sys::RequestCache::NoCache);
|
||||
init.set_signal(Some(&ctl.signal()));
|
||||
let on_timeout = Closure::once(move || ctl.abort());
|
||||
let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?;
|
||||
let out = read(&win, &init).await;
|
||||
win.clear_timeout_with_handle(timer);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`.
|
||||
async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option<Vec<u8>> {
|
||||
let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?;
|
||||
let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?;
|
||||
if resp.status() != 200 {
|
||||
return None;
|
||||
}
|
||||
let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into();
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let chunk = JsFuture::from(reader.read()).await.ok()?;
|
||||
if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) {
|
||||
return Some(out);
|
||||
}
|
||||
let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?;
|
||||
if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX {
|
||||
// Stop the download: the limit is enforced while reading, not after the whole body arrived.
|
||||
let _ = reader.cancel();
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&bytes.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
/// The config file's bytes (`None` when there is none), fetched on the first call.
|
||||
pub async fn bytes() -> Option<Vec<u8>> {
|
||||
if let Some(done) = FILE.with(|f| f.borrow().clone()) {
|
||||
return done;
|
||||
}
|
||||
let got = fetch().await;
|
||||
FILE.with(|f| *f.borrow_mut() = Some(got.clone()));
|
||||
got
|
||||
}
|
||||
|
||||
/// The configured startup behaviour.
|
||||
pub async fn startup() -> Startup {
|
||||
let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default();
|
||||
log::info!("startup: {s:?}");
|
||||
s
|
||||
}
|
||||
@@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> {
|
||||
|
||||
/// The next write of `path` goes to browser storage only (no download).
|
||||
pub fn save_quietly(path: &str) {
|
||||
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
|
||||
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
|
||||
/// downloaded at the next [`flush_downloads`].
|
||||
pub fn add_output(path: &str, data: Arc<[u8]>) {
|
||||
STORE.put_file(path, data, false);
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
@@ -120,7 +120,7 @@ impl Services for WebServices {
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
let quiet = {
|
||||
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
@@ -151,6 +151,10 @@ impl Services for WebServices {
|
||||
fn is_dir(&self, _path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
/// The table's keys are the paths themselves.
|
||||
fn full_path(&self, path: &str) -> Option<String> {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Media import from the table (the bytes are also handed to the media pool, which decodes them).
|
||||
@@ -184,7 +188,7 @@ pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
pub fn flush_downloads() {
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64);
|
||||
|
||||
thread_local! {
|
||||
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
|
||||
#[allow(clippy::type_complexity)]
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, (js_sys::Function, js_sys::Function, (u32, u32, Vec<u8>), f64)>> = RefCell::new(Default::default());
|
||||
static CHECKS: RefCell<std::collections::HashMap<u64, PendingFrameCheck>> = RefCell::new(Default::default());
|
||||
}
|
||||
|
||||
/// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
|
||||
@@ -416,6 +417,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
|
||||
fn new_server() -> FrameServer {
|
||||
let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)));
|
||||
s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default())));
|
||||
s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path))));
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -6,22 +6,23 @@
|
||||
//!
|
||||
//! | Desktop | Web |
|
||||
//! |---|---|
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table (`files`) persisted to the Origin Private File System / IndexedDB (`persist`, [`store`]) |
|
||||
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
|
||||
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API (`browse`) |
|
||||
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device ([`frames`]); `Frames::pump` on the UI thread without workers |
|
||||
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers ([`diskcache`]) |
|
||||
//! | (none) | the storage manager: usage, quota, persistence, clearing ([`storage`]) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs and its own WebGPU device (`frames`); `Frames::pump` on the UI thread without workers |
|
||||
//! | disk cache folder | frames in the Origin Private File System, written by the frame workers (`diskcache`) |
|
||||
//! | (none) | the storage manager: usage, quota, persistence, clearing (`storage`) |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance (`worker`) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) |
|
||||
//!
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod startup;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -33,6 +34,8 @@ pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod browse;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod config_file;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod diskcache;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load.
|
||||
//!
|
||||
//! | Value | On load |
|
||||
//! |---|---|
|
||||
//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit |
|
||||
//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo |
|
||||
//! | `"demo"` | the demo project, whatever the last visit left |
|
||||
//!
|
||||
//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an
|
||||
//! unknown value, a config that is not JSON) is the default, `"last"`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// What the page opens on load.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Startup {
|
||||
#[default]
|
||||
Last,
|
||||
Empty,
|
||||
Demo,
|
||||
}
|
||||
|
||||
/// The largest config file read (bytes).
|
||||
pub const CONFIG_MAX: usize = 64 * 1024;
|
||||
|
||||
/// The `startup` key of a config file's bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Startup {
|
||||
if bytes.len() > CONFIG_MAX {
|
||||
return Startup::default();
|
||||
}
|
||||
let Ok(v) = serde_json::from_slice::<Value>(bytes) else { return Startup::default() };
|
||||
match v.get("startup").and_then(Value::as_str) {
|
||||
Some("empty") => Startup::Empty,
|
||||
Some("demo") => Startup::Demo,
|
||||
_ => Startup::Last,
|
||||
}
|
||||
}
|
||||
|
||||
impl Startup {
|
||||
/// Whether the last visit's project is restored (off for the demo).
|
||||
pub fn restores(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the
|
||||
/// project the last visit left).
|
||||
pub fn keeps_session(self) -> bool {
|
||||
self != Startup::Demo
|
||||
}
|
||||
|
||||
/// Whether the demo project opens when nothing was restored (off for a blank start).
|
||||
pub fn demo_when_nothing_restored(self) -> bool {
|
||||
self != Startup::Empty
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
@@ -45,6 +46,8 @@ pub struct Entry {
|
||||
/// Which write this is: new with every write of the key, so a file replaced by another of
|
||||
/// the same size still reads as changed (workers are sent it again, #218).
|
||||
pub version: u64,
|
||||
/// FNV-1a of `data`, filled the first time a disk-cache key asks for it.
|
||||
hash: Option<u64>,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
@@ -69,13 +72,13 @@ impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
@@ -149,19 +152,36 @@ pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
|
||||
fn content_hash(data: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in data {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
/// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
|
||||
media_epoch: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
fn default() -> Self {
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
@@ -179,12 +199,40 @@ impl Store {
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
let key = file_key(path);
|
||||
let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
|
||||
self.lock().put(&key, data, now, persist);
|
||||
if replaced {
|
||||
self.media_epoch.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
|
||||
/// a different blob, and it is not a content hash.
|
||||
pub fn media_epoch(&self) -> Arc<AtomicU64> {
|
||||
self.media_epoch.clone()
|
||||
}
|
||||
|
||||
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
|
||||
/// later key does not walk the bytes again.
|
||||
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
|
||||
let key = file_key(path);
|
||||
let mut mirror = self.lock();
|
||||
let entry = mirror.entries.get(&key)?;
|
||||
if let Some(hash) = entry.hash {
|
||||
return Some((entry.data.len() as u64, hash));
|
||||
}
|
||||
let hash = content_hash(&entry.data);
|
||||
let len = entry.data.len() as u64;
|
||||
if let Some(entry) = mirror.entries.get_mut(&key) {
|
||||
entry.hash = Some(hash);
|
||||
}
|
||||
Some((len, hash))
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
@@ -318,6 +366,21 @@ impl ConfigStore for WebConfig {
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
/// The first name part of the keys under `dir/`.
|
||||
fn list_dirs(&self, dir: &str) -> Vec<String> {
|
||||
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
|
||||
let mut v: Vec<String> = self
|
||||
.store
|
||||
.lock()
|
||||
.list(&pre)
|
||||
.into_iter()
|
||||
.filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
@@ -446,6 +509,11 @@ mod tests {
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(&data[..], b"blue");
|
||||
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
|
||||
let epoch = s.media_epoch().load(Ordering::Relaxed);
|
||||
let id = s.file_identity("/collision.png").expect("identity");
|
||||
assert_eq!(id, (4, content_hash(b"blue")));
|
||||
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
|
||||
assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
|
||||
}
|
||||
|
||||
/// A write that failed is flushed again with the key's latest state (#215).
|
||||
@@ -480,6 +548,22 @@ mod tests {
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
/// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
|
||||
#[test]
|
||||
fn config_store_lists_folders() {
|
||||
let c = WebConfig::new(store());
|
||||
for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
|
||||
c.write(name, "x").unwrap();
|
||||
}
|
||||
assert_eq!(c.list("Scripts"), ["Top.jsx"]);
|
||||
assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
|
||||
assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
|
||||
let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
|
||||
let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
|
||||
let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
|
||||
assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mod startup_tests;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The `startup` key of the config file.
|
||||
|
||||
use effectcraft_web::startup::{CONFIG_MAX, Startup, parse};
|
||||
|
||||
/// The three accepted values parse.
|
||||
#[test]
|
||||
fn the_three_values() {
|
||||
assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo);
|
||||
assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last);
|
||||
}
|
||||
|
||||
/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`.
|
||||
#[test]
|
||||
fn absent_or_invalid_is_the_default() {
|
||||
for bytes in
|
||||
[&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#]
|
||||
{
|
||||
assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/// Other keys do not hide `startup`.
|
||||
#[test]
|
||||
fn a_config_with_other_keys_still_has_its_startup() {
|
||||
assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty);
|
||||
assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo);
|
||||
}
|
||||
|
||||
/// A file over `CONFIG_MAX` is ignored.
|
||||
#[test]
|
||||
fn an_oversized_file_is_the_default() {
|
||||
let mut big = br#"{"startup":"empty","pad":""#.to_vec();
|
||||
big.extend(std::iter::repeat_n(b'x', CONFIG_MAX));
|
||||
big.extend_from_slice(br#""}"#);
|
||||
assert_eq!(parse(&big), Startup::Last);
|
||||
}
|
||||
|
||||
/// Which project each value restores or opens.
|
||||
#[test]
|
||||
fn what_each_value_does_on_load() {
|
||||
assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored());
|
||||
assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored());
|
||||
assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored());
|
||||
}
|
||||
|
||||
/// Only the explicit demo start stops saving the session.
|
||||
#[test]
|
||||
fn only_the_demo_start_leaves_the_saved_session_alone() {
|
||||
assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session());
|
||||
assert!(!Startup::Demo.keeps_session());
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
|
||||
// the reload writes pending changes to browser storage first, and a failed write is reported
|
||||
// instead of reloading (#225).
|
||||
// - Duplicate cancels the browser's bookmark action on the app canvas, while still delivering
|
||||
// the key to the app; other chords and HTML inputs keep their browser behavior (#2495).
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -58,6 +60,30 @@ globalThis.location = { reload: () => reloads++ };
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
|
||||
// #2495: prevent the browser default without swallowing the app's Duplicate key.
|
||||
const canvas = new EventTarget();
|
||||
const input = new EventTarget();
|
||||
host.installCanvasShortcuts(canvas);
|
||||
let delivered = 0;
|
||||
canvas.addEventListener("keydown", () => delivered++);
|
||||
const key = (target, properties) => {
|
||||
const event = new Event("keydown", { cancelable: true });
|
||||
Object.assign(event, { key: "d", ...properties });
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
assert.equal(key(canvas, { ctrlKey: true }), true);
|
||||
assert.equal(key(canvas, { metaKey: true, key: "D" }), true);
|
||||
for (const properties of [{}, { ctrlKey: true, shiftKey: true }, { ctrlKey: true, altKey: true }, { ctrlKey: true, isComposing: true }, { ctrlKey: true, key: "r" }]) {
|
||||
assert.equal(key(canvas, properties), false);
|
||||
}
|
||||
// Non-Latin layouts: the physical D key types "в", and eframe still reads it as D.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "в", code: "KeyD" }), true);
|
||||
// Dvorak: the physical D key types "e", which is not Duplicate.
|
||||
assert.equal(key(canvas, { ctrlKey: true, key: "e", code: "KeyD" }), false);
|
||||
assert.equal(delivered, 9, "all keys still reach the app");
|
||||
assert.equal(key(input, { ctrlKey: true }), false, "an external HTML input keeps its shortcut");
|
||||
|
||||
// #226: one readable and one unreadable file.
|
||||
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
|
||||
const picked = host.browsePickFiles(false);
|
||||
|
||||
@@ -162,7 +162,7 @@ try {
|
||||
const info = await js("effectcraft.info()");
|
||||
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
|
||||
if (info.webgpu && info.backend === "BrowserWebGpu") {
|
||||
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu?.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ try {
|
||||
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
|
||||
report.steps.storage = st;
|
||||
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
|
||||
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
|
||||
check(st.diskCache?.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
|
||||
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
|
||||
await sleep(800);
|
||||
report.steps.storage.screenshot = await shot("01c-storage-manager");
|
||||
@@ -378,14 +378,14 @@ try {
|
||||
const jobs = gj.workers.jobs || [];
|
||||
const rj = jobs.filter((j) => j.kind === "Render").pop();
|
||||
const wj = jobs.filter((j) => j.kind === "Warp").pop();
|
||||
check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
|
||||
check(gj.workers.gpu?.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
|
||||
check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`);
|
||||
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
|
||||
check(rj?.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
|
||||
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
|
||||
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
|
||||
check(wj?.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
|
||||
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
|
||||
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
|
||||
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
|
||||
check(fj?.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
|
||||
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
|
||||
check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`);
|
||||
} else {
|
||||
@@ -418,7 +418,7 @@ try {
|
||||
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
|
||||
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
|
||||
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
|
||||
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
||||
check(!mbImport.pending && mbImport.items?.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
await js(`effectcraft.execute("time.set", {time: 0})`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
||||
@@ -579,7 +579,7 @@ try {
|
||||
report.steps.final2 = { screenshot: await shot("03-offline") };
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
report.error = String(e && e.stack || e);
|
||||
report.error = String(e?.stack || e);
|
||||
try { report.errorShot = await shot("error"); } catch {}
|
||||
}
|
||||
report.console = logs.slice(-40);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no
|
||||
// browser or Wasm build needed:
|
||||
//
|
||||
// node apps/effectcraft-web/tests/storage.mjs
|
||||
//
|
||||
// A write is acknowledged only once its transaction has committed, not when its request
|
||||
// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be
|
||||
// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
globalThis.self = globalThis;
|
||||
|
||||
// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite
|
||||
// transaction commits once its requests are done, unless it was aborted (which rolls back its
|
||||
// writes). `fault` makes the next request do something else.
|
||||
let fault = null; // "abortAfterSuccess" | "requestError" | "throw"
|
||||
const databases = new Map();
|
||||
|
||||
class Request {
|
||||
constructor(tx) {
|
||||
this.transaction = tx;
|
||||
this.result = undefined;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
class Store {
|
||||
constructor(tx, rows) {
|
||||
this.tx = tx;
|
||||
this.rows = rows;
|
||||
}
|
||||
request(apply) {
|
||||
const tx = this.tx;
|
||||
if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError");
|
||||
const f = fault;
|
||||
fault = null;
|
||||
if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError");
|
||||
const r = new Request(tx);
|
||||
tx.pending++;
|
||||
setTimeout(() => {
|
||||
tx.pending--;
|
||||
if (tx.finished) return;
|
||||
if (f === "requestError") {
|
||||
r.error = new DOMException("the key already exists", "ConstraintError");
|
||||
const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
r.onerror?.(e);
|
||||
tx.onerror?.(e);
|
||||
if (!e.defaultPrevented) tx.fail(r.error);
|
||||
return;
|
||||
}
|
||||
r.result = apply(tx.staged);
|
||||
r.onsuccess?.({ target: r });
|
||||
if (f === "abortAfterSuccess") tx.abort();
|
||||
tx.maybeCommit();
|
||||
}, 0);
|
||||
return r;
|
||||
}
|
||||
put(v) {
|
||||
return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; });
|
||||
}
|
||||
delete(k) {
|
||||
return this.request((rows) => { rows.delete(k); });
|
||||
}
|
||||
get(k) {
|
||||
return this.request((rows) => rows.get(k));
|
||||
}
|
||||
getAll() {
|
||||
return this.request((rows) => [...rows.values()]);
|
||||
}
|
||||
}
|
||||
|
||||
class Transaction {
|
||||
constructor(db, name) {
|
||||
this.db = db;
|
||||
this.name = name;
|
||||
this.staged = new Map(db.stores.get(name));
|
||||
this.pending = 0;
|
||||
this.finished = false;
|
||||
this.error = null;
|
||||
setTimeout(() => this.maybeCommit(), 0);
|
||||
}
|
||||
objectStore(name) {
|
||||
const s = new Store(this, this.staged);
|
||||
s.keyPath = this.db.keyPaths.get(name);
|
||||
return s;
|
||||
}
|
||||
maybeCommit() {
|
||||
if (this.finished || this.pending > 0) return;
|
||||
this.finished = true;
|
||||
this.db.stores.set(this.name, this.staged);
|
||||
setTimeout(() => this.oncomplete?.(), 0);
|
||||
}
|
||||
fail(error) {
|
||||
this.error = error;
|
||||
this.abort();
|
||||
}
|
||||
abort() {
|
||||
if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError");
|
||||
this.finished = true;
|
||||
setTimeout(() => this.onabort?.(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
class Database {
|
||||
constructor() {
|
||||
this.stores = new Map();
|
||||
this.keyPaths = new Map();
|
||||
}
|
||||
createObjectStore(name, { keyPath }) {
|
||||
this.stores.set(name, new Map());
|
||||
this.keyPaths.set(name, keyPath);
|
||||
}
|
||||
transaction(name) {
|
||||
if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError");
|
||||
return new Transaction(this, name);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.indexedDB = {
|
||||
open(name) {
|
||||
const r = new Request(null);
|
||||
setTimeout(() => {
|
||||
let db = databases.get(name);
|
||||
if (!db) {
|
||||
db = new Database();
|
||||
databases.set(name, db);
|
||||
r.result = db;
|
||||
r.onupgradeneeded?.();
|
||||
}
|
||||
r.result = db;
|
||||
r.onsuccess?.({ target: r });
|
||||
}, 0);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
|
||||
const host = await import(pathToFileURL(join(here, "../js/host.js")));
|
||||
assert.equal(await host.storeOpen("indexeddb"), "indexeddb");
|
||||
const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key);
|
||||
const bytes = (s) => new TextEncoder().encode(s);
|
||||
const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data));
|
||||
|
||||
// A healthy write resolves once committed, and the store has it then.
|
||||
await host.storePut("project.json", bytes("v1"), 1);
|
||||
assert.equal(text(stored("project.json")), "v1", "a resolved put is committed");
|
||||
|
||||
// The put's request succeeds, then the transaction aborts: the put fails, the old value stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back");
|
||||
|
||||
// The same for a delete: it fails and the entry stays.
|
||||
fault = "abortAfterSuccess";
|
||||
await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported");
|
||||
assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back");
|
||||
|
||||
// A failed request aborts its transaction with the request's error.
|
||||
fault = "requestError";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// A request that throws at once (a value that can't be stored) fails the write.
|
||||
fault = "throw";
|
||||
await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" });
|
||||
assert.equal(text(stored("project.json")), "v1");
|
||||
|
||||
// The retry after a failure commits the latest value, and a later delete removes it.
|
||||
await host.storePut("project.json", bytes("v5"), 5);
|
||||
assert.equal(text(stored("project.json")), "v5");
|
||||
await host.storeDelete("project.json");
|
||||
assert.equal(stored("project.json"), undefined, "a resolved delete is committed");
|
||||
const all = await host.storeLoadAll();
|
||||
assert.deepEqual(all, [], "nothing else was written");
|
||||
|
||||
await tick();
|
||||
console.log("storage: ok");
|
||||
@@ -7,6 +7,8 @@
|
||||
// (#218), and not when it is unchanged.
|
||||
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
|
||||
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
|
||||
// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a
|
||||
// frame worker is reported dead, instead of the exception escaping into startup.
|
||||
// Exits non-zero on failure.
|
||||
import assert from "node:assert/strict";
|
||||
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
@@ -67,6 +69,35 @@ assert.equal(failed.kind, "error");
|
||||
assert.ok(workers[1].terminated, "the failed worker is terminated");
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
|
||||
// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame
|
||||
// worker is reported dead, instead of the exception escaping into startup.
|
||||
failStart = false;
|
||||
const RealWorker = globalThis.Worker;
|
||||
globalThis.Worker = class {
|
||||
constructor() {
|
||||
throw new Error("SecurityError: cannot be accessed from origin 'null'");
|
||||
}
|
||||
};
|
||||
// (Take the idle workers out of play first, so that a new one has to be built.)
|
||||
for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) {
|
||||
host.workerRun(id, "{}", [], "http://localhost/", () => {});
|
||||
host.workerCancel(id);
|
||||
}
|
||||
assert.equal(host.workerStats().idle, 0);
|
||||
const threw = await run(6, []);
|
||||
assert.equal(threw.kind, "error");
|
||||
assert.match(threw.json, /SecurityError/);
|
||||
assert.equal(host.workerStats().running, 0);
|
||||
let frameCall = null;
|
||||
const frameDone = new Promise((resolve) => {
|
||||
frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false);
|
||||
});
|
||||
const dead = await frameDone;
|
||||
assert.equal(dead.index, frameCall, "the dead worker keeps its index");
|
||||
assert.match(dead.error, /SecurityError/);
|
||||
host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored
|
||||
globalThis.Worker = RealWorker;
|
||||
|
||||
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
|
||||
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
|
||||
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
|
||||
|
||||
@@ -71,7 +71,13 @@
|
||||
...ec.info(),
|
||||
crossOriginIsolated: self.crossOriginIsolated,
|
||||
webgpu: !!navigator.gpu,
|
||||
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
|
||||
serviceWorker: (() => {
|
||||
try {
|
||||
return !!(navigator.serviceWorker && navigator.serviceWorker.controller);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
load: window.effectcraftLoad,
|
||||
}),
|
||||
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
|
||||
@@ -83,8 +89,14 @@
|
||||
workerFrameCheck: (params) => ec.workerFrameCheck(params ?? {}).then(JSON.parse),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
if (!new URLSearchParams(location.search).has("nosw")) {
|
||||
try {
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("service worker:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Compile once: the page and its render workers instantiate the same module.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
|
||||
// cache named after the version, served cache-first (responses keep the server's headers, so
|
||||
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time.
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time,
|
||||
// except the config file, which must stay live.
|
||||
const VERSION = "__EC_VERSION__";
|
||||
const FILES = __EC_FILES__;
|
||||
const CACHE = `effectcraft-${VERSION}`;
|
||||
@@ -30,15 +31,18 @@ self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
if (url.pathname.endsWith("/effectcraft-config.json")) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
|
||||
if (hit) return hit;
|
||||
const cache = await caches.open(CACHE).catch(() => null);
|
||||
if (cache) {
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const res = await fetch(req);
|
||||
if (res.ok && res.type === "basic") cache.put(req, res.clone());
|
||||
if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
|
||||
return res;
|
||||
})(),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ effectcraft-host = { workspace = true }
|
||||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
|
||||
@@ -48,3 +48,6 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
|
||||
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
+126
-21
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! `--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)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
mod appearance;
|
||||
@@ -23,8 +23,8 @@ mod control_server;
|
||||
mod launch_guard;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
// Built everywhere so it stays checked; only the macOS app uses it.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
// Built everywhere so it stays checked; the macOS and Linux apps use it.
|
||||
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
|
||||
mod sheet_dialogs;
|
||||
mod window;
|
||||
|
||||
@@ -50,7 +50,7 @@ fn app_icon() -> egui::IconData {
|
||||
fn is_project_file(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
|
||||
}
|
||||
|
||||
@@ -122,7 +122,42 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands' file dialogs open without blocking the frame where the host's backend would:
|
||||
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
|
||||
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
|
||||
/// answer without pumping events, so the window stops answering the compositor and the desktop
|
||||
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
|
||||
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
|
||||
/// The dialogs asked for in a frame show at its end ([`sheet_dialogs::show_asked`]), where the
|
||||
/// main window's handle is at hand.
|
||||
fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>> {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel::<sheet_dialogs::Asked>();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| {
|
||||
if tx.send((dialog, done)).is_err() {
|
||||
log::error!("file dialog: the window is gone");
|
||||
}
|
||||
}));
|
||||
Some(rx)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a normal desktop launch: install logging and the panic hook, parse arguments (`--version` exits before the
|
||||
/// session log is attached), scan fonts, honor the launch guard's backend choice and open the eframe window.
|
||||
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
|
||||
/// reports startup failure through `startup_failed`. Returns the event loop's result.
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
|
||||
// feed the compatibility report.
|
||||
@@ -187,6 +222,7 @@ fn main() -> eframe::Result {
|
||||
"EffectCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
match &cc.wgpu_render_state {
|
||||
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
|
||||
None => log::info!("graphics: no wgpu device"),
|
||||
@@ -227,6 +263,9 @@ fn main() -> eframe::Result {
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
|
||||
// a toast).
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
@@ -247,6 +286,7 @@ fn main() -> eframe::Result {
|
||||
// reported in a toast and forgotten).
|
||||
let _ = session.execute("window.restoreScriptPanels", json!({}));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_startup_update_check(true);
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = show_home;
|
||||
if let Some(n) = notice {
|
||||
@@ -301,11 +341,11 @@ fn main() -> eframe::Result {
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
// The commands' file dialogs don't block the frame where the platform's backend
|
||||
// would (#511, #1275); see [`install_file_dialogs`].
|
||||
let file_dialogs = install_file_dialogs(&mut app);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
|
||||
app.hooks.app_action = Some(Box::new(native_menu::app_action));
|
||||
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
|
||||
}
|
||||
@@ -321,20 +361,18 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&cc.egui_ctx),
|
||||
app,
|
||||
file_dialogs,
|
||||
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
|
||||
launch,
|
||||
drawn: false,
|
||||
}))
|
||||
}),
|
||||
);
|
||||
match &result {
|
||||
Ok(()) => log::info!("the window closed"),
|
||||
Err(e) => {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
if let Err(e) = &result {
|
||||
if on_gl {
|
||||
launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
|
||||
}
|
||||
startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -343,9 +381,15 @@ fn main() -> eframe::Result {
|
||||
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
|
||||
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
|
||||
/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
|
||||
///
|
||||
/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one
|
||||
/// frame in flight, so a drag or a scrub shows one frame sooner. The bare
|
||||
/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three
|
||||
/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms
|
||||
/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window.
|
||||
fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
|
||||
use eframe::egui_wgpu::WgpuSetup;
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY);
|
||||
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
if let Some(b) = backends {
|
||||
setup.instance_descriptor.backends = b;
|
||||
@@ -355,15 +399,22 @@ fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfigu
|
||||
// Iris Pro: the device is lost and the window never opens.
|
||||
// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
|
||||
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
|
||||
label: Some("EffectCraft device"),
|
||||
required_limits: device_limits(adapter.limits()),
|
||||
..Default::default()
|
||||
});
|
||||
setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
|
||||
/// so memory the compositor frees on the shared device goes back to the driver.
|
||||
fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
|
||||
eframe::wgpu::DeviceDescriptor {
|
||||
label: Some("EffectCraft device"),
|
||||
required_limits: device_limits(adapter),
|
||||
memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
|
||||
/// capped at 16384 px like the headless renderer's device.
|
||||
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
|
||||
@@ -395,6 +446,8 @@ fn startup_failed(e: &eframe::Error, gl_next: bool) {
|
||||
/// The desktop app: the egui app plus, on macOS, the system menu bar.
|
||||
struct Desktop {
|
||||
app: EffectcraftApp,
|
||||
/// The commands' file dialogs, shown at the end of the frame that asked for them.
|
||||
file_dialogs: Option<std::sync::mpsc::Receiver<sheet_dialogs::Asked>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: native_menu::NativeBar,
|
||||
/// Documents opened from Finder, the Dock or `open` (macOS).
|
||||
@@ -408,14 +461,24 @@ struct Desktop {
|
||||
}
|
||||
|
||||
impl eframe::App for Desktop {
|
||||
/// The desktop host's logic tick: update the macOS menu bar, run the shared app tick, close the IME pass, show the
|
||||
/// file dialogs asked for and, last, hand a close to the platform quit.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.menu.update(&mut self.app, ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.tick(ctx, frame);
|
||||
self.app.finish_ime_logic(ctx);
|
||||
if let Some(asked) = &self.file_dialogs {
|
||||
sheet_dialogs::show_asked(asked, frame, ctx);
|
||||
}
|
||||
effectcraft_ui_egui::panels::unsaved::quit_on_close(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
if let Some(asked) = &self.file_dialogs {
|
||||
sheet_dialogs::show_asked(asked, frame, ui.ctx());
|
||||
}
|
||||
self.window.frame(ui.ctx(), self.app.session.config.as_deref());
|
||||
if !self.drawn {
|
||||
self.drawn = true;
|
||||
@@ -429,9 +492,14 @@ impl eframe::App for Desktop {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
|
||||
// before `eframe::run_native` returns (#341).
|
||||
log::info!("the window is closing");
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
// Scripts in Scripts/Shutdown run with the project closed.
|
||||
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
@@ -510,6 +578,19 @@ mod tests {
|
||||
assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
/// The window's surface keeps one frame in flight, like eframe's own default, not the two
|
||||
/// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag).
|
||||
#[test]
|
||||
fn the_window_surface_is_low_latency() {
|
||||
for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] {
|
||||
let surface = super::wgpu_options(backends).surface;
|
||||
assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}");
|
||||
assert_eq!(surface.desired_maximum_frame_latency, Some(1));
|
||||
assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync);
|
||||
}
|
||||
assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface");
|
||||
}
|
||||
|
||||
/// The window's device never asks for more than the adapter offers (#198).
|
||||
#[test]
|
||||
fn device_limits_fit_the_adapter() {
|
||||
@@ -523,6 +604,17 @@ mod tests {
|
||||
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
|
||||
}
|
||||
|
||||
/// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
|
||||
#[test]
|
||||
fn the_window_device_favours_memory_usage() {
|
||||
use eframe::wgpu::Limits;
|
||||
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
|
||||
let d = super::device_descriptor(big);
|
||||
assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
|
||||
assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
|
||||
assert_eq!(d.label, Some("EffectCraft device"));
|
||||
}
|
||||
|
||||
/// A positional .ecprojx is a project file, not an unsupported media import (#528).
|
||||
#[test]
|
||||
fn launch_classifies_xml_and_json_projects_as_projects() {
|
||||
@@ -597,4 +689,17 @@ mod tests {
|
||||
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
|
||||
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
|
||||
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
|
||||
#[test]
|
||||
fn commands_file_dialogs_do_not_block_the_frame() {
|
||||
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let asked = super::install_file_dialogs(&mut app);
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
assert!(app.hooks.file_dialog_async.is_some() && asked.is_some(), "the commands' file dialogs must not block the frame");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
assert!(app.hooks.file_dialog_async.is_none() && asked.is_none(), "the blocking dialog pumps its own messages here");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
//! [`effectcraft_ui_egui::native_menu`]'s spec of the engine's After Effects menu tree.
|
||||
//!
|
||||
//! Each frame [`NativeBar::update`] runs menu activations (the same command ids as the in-window
|
||||
//! bar), and when [`native_menu::state_key`] changed it rebuilds the spec and pushes labels,
|
||||
//! enabled and check state into the existing items; a structural change (Open Recent, a new
|
||||
//! shortcut preset) rebuilds the menu. Settings ▸ Appearance ▸ Use In-Window Menu Bar on macOS
|
||||
//! swaps back to the in-window bar.
|
||||
//! bar), and when [`native_menu::state_key`] or whether a text field has the keyboard changed it
|
||||
//! rebuilds the spec and pushes labels, enabled and check state into the existing items; a
|
||||
//! structural change (Open Recent, a new shortcut preset) rebuilds the menu. Settings ▸
|
||||
//! Appearance ▸ Use In-Window Menu Bar on macOS swaps back to the in-window bar.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
@@ -24,7 +24,7 @@ pub struct NativeBar {
|
||||
items: HashMap<String, CheckMenuItem>,
|
||||
spec: NativeMenu,
|
||||
structure: u64,
|
||||
state: Option<u64>,
|
||||
state: Option<(u64, bool)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
/// The in-window-bar setting last applied (None: not yet).
|
||||
in_window: Option<bool>,
|
||||
@@ -67,12 +67,13 @@ impl NativeBar {
|
||||
if in_window {
|
||||
return;
|
||||
}
|
||||
let key = native_menu::state_key(app);
|
||||
// While a text field has the keyboard, the Edit items it takes stay enabled.
|
||||
let key = (native_menu::state_key(app), native_menu::text_field_window(ctx).is_some());
|
||||
if self.state == Some(key) && self.menu.is_some() {
|
||||
return;
|
||||
}
|
||||
self.state = Some(key);
|
||||
let spec = native_menu::build(app);
|
||||
let spec = native_menu::build_now(app, ctx);
|
||||
let structure = spec.structure();
|
||||
if self.menu.is_none() || structure != self.structure {
|
||||
self.install(&spec);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
|
||||
//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`.
|
||||
//!
|
||||
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
|
||||
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
|
||||
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
|
||||
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
|
||||
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
|
||||
//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so
|
||||
//! the main thread sits waiting on the portal's answer and never pumps winit's events — the
|
||||
//! window stops answering the compositor and the desktop offers to force-quit the app while the
|
||||
//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window
|
||||
//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer
|
||||
//! arrives when the dialog closes, outside the frame, and the app runs the command then.
|
||||
|
||||
use effectcraft_ui_egui::{DialogDone, FileDialog};
|
||||
use rfd::{AsyncFileDialog, FileHandle};
|
||||
@@ -13,10 +17,24 @@ use std::pin::Pin;
|
||||
|
||||
type Answer = Pin<Box<dyn Future<Output = Vec<String>> + Send>>;
|
||||
|
||||
/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it
|
||||
/// closes, then wake the UI.
|
||||
pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
|
||||
let answer = open(dialog);
|
||||
/// A command's dialog waiting for [`show_asked`].
|
||||
pub type Asked = (FileDialog, DialogDone);
|
||||
|
||||
/// Show the dialogs the commands asked for in this frame (on the main thread: a sheet opens
|
||||
/// here) and hand each one's paths to its `done` once it closes, then wake the UI.
|
||||
///
|
||||
/// On macOS the sheet goes on the main window (`frame`'s). Without a parent rfd puts it on
|
||||
/// AppKit's main window, which is a floating panel's OS window after a click in one; that
|
||||
/// window hides while the app has no focused window, and a sheet whose window goes away ends
|
||||
/// without answering, with later sheets queued behind it on the same window.
|
||||
pub fn show_asked(asked: &std::sync::mpsc::Receiver<Asked>, frame: &eframe::Frame, ctx: &egui::Context) {
|
||||
while let Ok((dialog, done)) = asked.try_recv() {
|
||||
show(dialog, done, frame, ctx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn show(dialog: FileDialog, done: DialogDone, frame: &eframe::Frame, ctx: egui::Context) {
|
||||
let answer = open(dialog, frame);
|
||||
let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || {
|
||||
done(pollster::block_on(answer));
|
||||
ctx.request_repaint();
|
||||
@@ -26,6 +44,13 @@ pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog on the main window. Linux keeps the portal's dialog unparented: rfd exports the
|
||||
/// window for the portal from the dialog's worker thread there.
|
||||
fn parented(frame: &eframe::Frame) -> AsyncFileDialog {
|
||||
let d = AsyncFileDialog::new();
|
||||
if cfg!(target_os = "macos") { d.set_parent(frame) } else { d }
|
||||
}
|
||||
|
||||
fn paths(files: Option<Vec<FileHandle>>) -> Vec<String> {
|
||||
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
|
||||
}
|
||||
@@ -35,10 +60,10 @@ fn path(file: Option<FileHandle>) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// The dialog, as the blocking hooks in `main.rs` show it.
|
||||
fn open(dialog: FileDialog) -> Answer {
|
||||
fn open(dialog: FileDialog, frame: &eframe::Frame) -> Answer {
|
||||
match dialog {
|
||||
FileDialog::Open { exts, many } => {
|
||||
let d = AsyncFileDialog::new().add_filter("Media", &exts);
|
||||
let d = parented(frame).add_filter("Media", &exts);
|
||||
if many {
|
||||
let f = d.pick_files();
|
||||
Box::pin(async move { paths(f.await) })
|
||||
@@ -48,22 +73,22 @@ fn open(dialog: FileDialog) -> Answer {
|
||||
}
|
||||
}
|
||||
FileDialog::OpenProject => {
|
||||
let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
|
||||
let f = parented(frame).add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Folder => {
|
||||
let f = AsyncFileDialog::new().pick_folder();
|
||||
let f = parented(frame).pick_folder();
|
||||
Box::pin(async move { path(f.await) })
|
||||
}
|
||||
FileDialog::Save { default, ext, project } => {
|
||||
let at = std::path::Path::new(&default);
|
||||
let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut d = if project {
|
||||
AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"])
|
||||
parented(frame).add_filter("EffectCraft Project", &["ecproj"])
|
||||
} else if ext.is_empty() {
|
||||
AsyncFileDialog::new()
|
||||
parented(frame)
|
||||
} else {
|
||||
AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()])
|
||||
parented(frame).add_filter(&ext, &[ext.as_str()])
|
||||
};
|
||||
// A default with a folder that exists opens the dialog there.
|
||||
if let Some(dir) = at.parent().filter(|d| d.is_dir()) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! The desktop binary rejects a bad command line before it starts anything (#997).
|
||||
// Integration tests: unwrapping and panicking on failure is fine here.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_effectcraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_effectcraft").into())
|
||||
}
|
||||
|
||||
/// A settings folder of its own, so a launch that got past the arguments would show up as
|
||||
/// preferences (Recent Footage) written there instead of in the user's settings.
|
||||
fn config_dir(name: &str) -> PathBuf {
|
||||
@@ -19,7 +22,7 @@ fn config_dir(name: &str) -> PathBuf {
|
||||
/// regression fails instead of leaving a window open: the process is killed after 5 s.
|
||||
fn rejects(name: &str, args: &[&str], env: &[(&str, &str)], message: &str) {
|
||||
let config = config_dir(name);
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_effectcraft"));
|
||||
let mut cmd = Command::new(binary());
|
||||
cmd.args(args).env_remove("EFFECTCRAFT_CONTROL_PORT").env_remove("RUST_LOG").env("EFFECTCRAFT_CONFIG_DIR", &config);
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
@@ -67,7 +70,7 @@ fn a_bad_control_port_stops_the_launch() {
|
||||
|
||||
#[test]
|
||||
fn version_still_prints_with_a_bad_port_variable() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_effectcraft")).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
let out = Command::new(binary()).arg("--version").env("EFFECTCRAFT_CONTROL_PORT", "abc").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("effectcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
asset: NotoSansSymbols2-Regular.ttf
|
||||
title: Noto Sans Symbols 2 Regular (chrome symbols such as ✔ and ▣)
|
||||
author: The Noto Project Authors
|
||||
source: https://github.com/notofonts/symbols (Google Fonts commit 47d54ca1ebb14fd864695f1d39dc734f39017c33, file ofl/notosanssymbols2/NotoSansSymbols2-Regular.ttf)
|
||||
license: OFL-1.1
|
||||
license-file: assets/fonts/OFL-NotoSansSymbols2.txt
|
||||
added: 2026-10-24 by @skylartaylor
|
||||
notes: Unmodified font file, sha256 7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21. Embedded with include_bytes! in crates/ui-egui/src/theme.rs as the last UI fallback so every family can draw the chrome symbols (U+2714 ✔, U+25A3 ▣) that Inter, Inter Medium, Inter SemiBold and JetBrains Mono lack.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
@@ -22,6 +22,11 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
@@ -66,7 +71,7 @@ sync_github_name = true
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.nanox333]
|
||||
display_name = "nanox333"
|
||||
display_name = "nnaannoo"
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
@@ -87,3 +92,22 @@ sync_github_name = false
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ki2pixel]
|
||||
display_name = "ki2pixel"
|
||||
|
||||
[people.becauseno7]
|
||||
real_name = "Shuvra Basak"
|
||||
display_name = "Shuvra Basak"
|
||||
sync_github_name = false
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
@@ -7,7 +7,9 @@
|
||||
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
|
||||
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
|
||||
//! Render Queue) are checked against where they would write. Commands that run code or open
|
||||
//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined.
|
||||
//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
|
||||
//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
|
||||
//! access the user withheld.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -28,13 +30,21 @@ pub struct Confinement {
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Commands that run code, act outside the project or open other applications.
|
||||
/// Commands that run code, act outside the project or open other applications, besides the
|
||||
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
|
||||
const REFUSED: &[&str] = &[
|
||||
"script.run",
|
||||
"file.runScript",
|
||||
"file.installScript",
|
||||
"file.installScriptUIPanel",
|
||||
"file.uninstallScript",
|
||||
// A ScriptUI panel's script runs when the panel opens, when an event reaches one of its
|
||||
// controls, and when one of its `app.scheduleTask` tasks comes due.
|
||||
"window.scriptPanel",
|
||||
"window.restoreScriptPanels",
|
||||
"script.runTasks",
|
||||
"scriptui.click",
|
||||
"scriptui.set",
|
||||
"scriptui.mouse",
|
||||
"scriptui.close",
|
||||
"file.executeFile",
|
||||
"file.revealInFinder",
|
||||
"layer.openSource",
|
||||
@@ -64,8 +74,26 @@ const READS: &[(&str, &[&str])] = &[
|
||||
("effect.plugins.load", &["path", "folder"]),
|
||||
("mediaBrowser.go", &["path"]),
|
||||
("mediaBrowser.addFavorite", &["path"]),
|
||||
// #1243: these read the files they are given but were missing, so a relative path went
|
||||
// unchecked.
|
||||
("essential.templateInfo", &["path"]),
|
||||
("file.importProject", &["path"]),
|
||||
("file.watchFolder.poll", &["folder"]),
|
||||
("footage.relink", &["path"]),
|
||||
("layer.newModel", &["path"]),
|
||||
("mediaBrowser.fileInfo", &["path"]),
|
||||
("mediaBrowser.list", &["path"]),
|
||||
("mediaBrowser.import", &["paths"]),
|
||||
("mediaBrowser.removeFavorite", &["path"]),
|
||||
("face.model.install", &["path"]),
|
||||
("roto.model.install", &["path"]),
|
||||
// Reads the template at `path`; what it writes is in `MIXED_WRITES`.
|
||||
("templates.create", &["path"]),
|
||||
];
|
||||
|
||||
/// Commands that read some parameters (listed in [`READS`]) and write others.
|
||||
const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])];
|
||||
|
||||
/// Commands that write files named by these parameters.
|
||||
const WRITES: &[(&str, &[&str])] = &[
|
||||
("file.save", &["path"]),
|
||||
@@ -84,6 +112,9 @@ const WRITES: &[(&str, &[&str])] = &[
|
||||
("essential.exportTemplate", &["path"]),
|
||||
("shortcuts.export", &["path"]),
|
||||
("renderQueue.add", &["output", "path"]),
|
||||
("file.exportTimeline", &["path"]),
|
||||
// `path` / `output` may also be a file-name template, as for render.preRender.
|
||||
("file.createProxy", &["path", "output"]),
|
||||
];
|
||||
|
||||
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
|
||||
@@ -135,16 +166,26 @@ impl Confinement {
|
||||
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
|
||||
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
|
||||
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
|
||||
if REFUSED.contains(&id) {
|
||||
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
|
||||
}
|
||||
// Scripts can't grant themselves the access the user withheld
|
||||
// (`effectcraft_engine::prefs::Scripting::same_permissions`), and neither may a confined
|
||||
// agent: `prefs.set` saves the settings file, so a script run long after this session
|
||||
// would still have it. (`prefs.reset` only puts the defaults back, which grant nothing.)
|
||||
if id == "prefs.set" && prefs_keys(params).iter().any(|k| is_scripting_pref(k)) {
|
||||
return Err(Error::BadArgs(
|
||||
"`prefs.set`: the Scripting settings decide what a script may do, so they can't be changed while file access is confined".into(),
|
||||
));
|
||||
}
|
||||
if id == "engine.batch" {
|
||||
for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue };
|
||||
let inner_params = step.get("params").cloned().unwrap_or(Value::Null);
|
||||
let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]);
|
||||
let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]);
|
||||
// A step's `$N` reference is resolved only as the batch runs: never for a file.
|
||||
if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) {
|
||||
if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) {
|
||||
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
|
||||
}
|
||||
if self.check_command(inner, &inner_params)?.is_some() {
|
||||
@@ -170,7 +211,7 @@ impl Confinement {
|
||||
for p in paths {
|
||||
// A Render Queue output may name a template instead of a file.
|
||||
match p.as_str() {
|
||||
Some(s) if id == "render.preRender" && !looks_like_path(s) => {}
|
||||
Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {}
|
||||
Some(s) => {
|
||||
self.check(s, access)?;
|
||||
}
|
||||
@@ -178,9 +219,18 @@ impl Confinement {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parameters of a reading command that it writes to.
|
||||
let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]);
|
||||
for key in mixed {
|
||||
if let Some(p) = params.get(*key).filter(|v| !v.is_null()) {
|
||||
let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?;
|
||||
self.check(p, Access::Write)?;
|
||||
}
|
||||
}
|
||||
// Any other absolute path the command is given.
|
||||
let named: Vec<&str> = named.iter().chain(mixed).copied().collect();
|
||||
let mut other = vec![];
|
||||
absolute_strings(params, named, &mut other);
|
||||
absolute_strings(params, &named, &mut other);
|
||||
for s in other {
|
||||
self.check(&s, access)?;
|
||||
}
|
||||
@@ -218,6 +268,22 @@ impl Confinement {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `prefs.set` key reaches the Scripting page. `Prefs::set` turns the key into a JSON
|
||||
/// pointer (`key.replace('.', "/")`), so `scripting/allowScriptsWriteFiles` resolves as well as
|
||||
/// the dotted spelling, and the page on its own replaces every setting on it.
|
||||
fn is_scripting_pref(key: &str) -> bool {
|
||||
key.split(['.', '/']).next().is_some_and(|page| page.eq_ignore_ascii_case("scripting"))
|
||||
}
|
||||
|
||||
/// The settings `prefs.set` is given: `key`, and the keys of `values`.
|
||||
fn prefs_keys(params: &Value) -> Vec<&str> {
|
||||
let mut keys: Vec<&str> = params.get("key").and_then(Value::as_str).into_iter().collect();
|
||||
if let Some(Value::Object(m)) = params.get("values") {
|
||||
keys.extend(m.keys().map(String::as_str));
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> {
|
||||
table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k)
|
||||
}
|
||||
@@ -274,3 +340,115 @@ fn resolve(p: &Path) -> Option<PathBuf> {
|
||||
at = at.parent()?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Commands whose parameters are named like files but aren't: property paths
|
||||
/// (`path|prop`), project folders, layers to track or target.
|
||||
const NOT_FILES: &[&str] = &[
|
||||
"camera.createFromSolve",
|
||||
"edit.label",
|
||||
"edit.pasteReversedKeyframes",
|
||||
"effect.editDropdown",
|
||||
"essential.addProperty",
|
||||
"essential.canAdd",
|
||||
"essential.linkProperty",
|
||||
"essential.unlinkProperty",
|
||||
"keys.paste",
|
||||
"keys.set",
|
||||
"keys.setEase",
|
||||
"keys.setSpatialTangents",
|
||||
"paths.nullsFollowPoints",
|
||||
"paths.pointsFollowNulls",
|
||||
"paths.tracePath",
|
||||
"project.move",
|
||||
"prop.addKey",
|
||||
"prop.convertExpressionToKeyframes",
|
||||
"prop.deselect",
|
||||
"prop.editGradient",
|
||||
"prop.get",
|
||||
"prop.pickWhip",
|
||||
"prop.reset",
|
||||
"prop.select",
|
||||
"prop.set",
|
||||
"prop.setExpression",
|
||||
"prop.toggleAnimation",
|
||||
"prop.toggleKey",
|
||||
"track.new",
|
||||
"track.property",
|
||||
"track.setTarget",
|
||||
];
|
||||
|
||||
/// Every engine command with a parameter named like a file is classified, so a new one
|
||||
/// can't silently skip confinement (#1243): it reads or writes the files it is given, is
|
||||
/// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs
|
||||
/// (checked when the queue renders), or is listed in `NOT_FILES`.
|
||||
#[test]
|
||||
fn every_command_with_file_parameters_is_classified() {
|
||||
const FILE_KEYS: &[&str] =
|
||||
&["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"];
|
||||
let mut missing = vec![];
|
||||
for spec in effectcraft_engine::command_specs() {
|
||||
let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue };
|
||||
let id = spec.id;
|
||||
let classified = keys_of(READS, id).is_some()
|
||||
|| keys_of(WRITES, id).is_some()
|
||||
|| REFUSED.contains(&id)
|
||||
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
|
||||
|| IMPLICIT_WRITES.contains(&id)
|
||||
|| id.starts_with("renderQueue.")
|
||||
|| id == "render.addOutputModule"
|
||||
|| NOT_FILES.contains(&id);
|
||||
if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified {
|
||||
missing.push(format!("{id} {}", spec.params));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n"));
|
||||
// And every listed command exists.
|
||||
for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) {
|
||||
assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_outside_paths_of_newly_listed_commands_are_refused() {
|
||||
let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere"));
|
||||
for d in [&read, &write, &outside] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
|
||||
let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned();
|
||||
let up = format!("{}/../elsewhere/t.ectemplate", read.display());
|
||||
for (id, key) in [
|
||||
("essential.templateInfo", "path"),
|
||||
("file.importProject", "path"),
|
||||
("footage.relink", "path"),
|
||||
("layer.newModel", "path"),
|
||||
("mediaBrowser.fileInfo", "path"),
|
||||
("mediaBrowser.list", "path"),
|
||||
("face.model.install", "path"),
|
||||
("templates.create", "path"),
|
||||
] {
|
||||
for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] {
|
||||
assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}");
|
||||
}
|
||||
assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root");
|
||||
}
|
||||
// Batched too.
|
||||
let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]});
|
||||
assert!(c.check_command("engine.batch", &steps).is_err());
|
||||
// Writes: into a write root only.
|
||||
assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err());
|
||||
assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok());
|
||||
let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project});
|
||||
assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath");
|
||||
assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok());
|
||||
assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name");
|
||||
assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() {
|
||||
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}}));
|
||||
assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B");
|
||||
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true}));
|
||||
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
|
||||
|
||||
let (c, err) = call(&mut s, "render_frame", json!({}));
|
||||
assert!(!err, "{c:?}");
|
||||
@@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with
|
||||
/// only `id` and `label`, effects with only `id`, `name` and `category`.
|
||||
#[test]
|
||||
fn compact_listings_keep_only_ids_and_names() {
|
||||
let mut s = server();
|
||||
let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true}));
|
||||
assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}");
|
||||
let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true}));
|
||||
assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}");
|
||||
// Each compact listing is the full one with the same arguments, entry for entry; `compact`
|
||||
// wins over `schemas`.
|
||||
let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| {
|
||||
let full = call_json(s, tool, args.clone());
|
||||
let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect();
|
||||
let mut compact = args.clone();
|
||||
compact["compact"] = json!(true);
|
||||
assert_eq!(call_json(s, tool, compact), want, "{tool} {args}");
|
||||
};
|
||||
for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] {
|
||||
check(&mut s, "list_commands", args.clone(), &["id", "label"]);
|
||||
check(&mut s, "command_list", args, &["id", "label"]);
|
||||
}
|
||||
check(&mut s, "list_effects", json!({}), &["id", "name", "category"]);
|
||||
check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]);
|
||||
// `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it.
|
||||
let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string();
|
||||
for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] {
|
||||
let full = text(&mut s, tool, json!({}));
|
||||
assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}");
|
||||
let compact = text(&mut s, tool, json!({"compact": true})).len();
|
||||
assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_level_tools_add_effect_and_report_state() {
|
||||
let mut s = server();
|
||||
@@ -1376,7 +1412,76 @@ fn automation_roots_confine_file_access() {
|
||||
// Scripts can read and write anything: refused.
|
||||
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
|
||||
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
|
||||
for (id, params) in [
|
||||
("file.runStartupScripts", json!({})),
|
||||
("file.runShutdownScripts", json!({})),
|
||||
// ScriptUI panels run their script when they open.
|
||||
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
|
||||
("window.restoreScriptPanels", json!({})),
|
||||
] {
|
||||
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
}
|
||||
// Writes to the files the agent named: none outside.
|
||||
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file
|
||||
/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens,
|
||||
/// when an event reaches one of its controls, or when one of its tasks comes due — and
|
||||
/// `prefs.set` could grant every script run afterwards the file and network access the user
|
||||
/// withheld, in the settings file, long after the session. All of that is refused too.
|
||||
#[test]
|
||||
fn automation_roots_refuse_running_script_code() {
|
||||
use crate::confine::Confinement;
|
||||
let base = std::env::temp_dir().join(format!("ec-mcp-scripts-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let mut s = server();
|
||||
s.backend().confine(Confinement::new(&[], std::slice::from_ref(&base)).unwrap());
|
||||
let refused = |s: &mut McpServer, name: &str, args: Value| {
|
||||
let (c, err) = call(s, name, args.clone());
|
||||
assert!(err, "{name} {args} should be refused: {c:?}");
|
||||
c[0]["text"].as_str().unwrap_or_default().to_string()
|
||||
};
|
||||
// Every way a script's code starts.
|
||||
for (command, params) in [
|
||||
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
|
||||
("window.restoreScriptPanels", json!({})),
|
||||
("script.runTasks", json!({})),
|
||||
("scriptui.click", json!({"window": 1, "widget": 2})),
|
||||
("scriptui.set", json!({"window": 1, "widget": 2, "value": 1})),
|
||||
("scriptui.mouse", json!({"window": 1, "widget": 2, "type": "click"})),
|
||||
("scriptui.close", json!({"window": 1})),
|
||||
] {
|
||||
let msg = refused(&mut s, "execute_command", json!({"command": command, "params": params}));
|
||||
assert!(msg.contains("not available while file access is confined"), "`{command}`: {msg}");
|
||||
}
|
||||
// Including as a batch step.
|
||||
let msg = refused(&mut s, "batch", json!({"steps": [{"command": "window.scriptPanel", "params": {"name": "x.jsx"}}]}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{msg}");
|
||||
// What scripts may do stays as the user left it, by either spelling of the setting.
|
||||
for params in [
|
||||
json!({"key": "scripting.allowScriptsWriteFiles", "value": true}),
|
||||
json!({"key": "scripting.allow_scripts_write_files", "value": true}),
|
||||
json!({"values": {"scripting.warnExecutingFiles": false}}),
|
||||
json!({"values": {"general.showToolTips": false, "scripting.enableJsDebugger": true}}),
|
||||
// `Prefs::set` makes a JSON pointer of the key, so this spelling resolves too…
|
||||
json!({"key": "scripting/allowScriptsWriteFiles", "value": true}),
|
||||
// …and the page on its own replaces every setting on it.
|
||||
json!({"key": "scripting", "value": {"allowScriptsWriteFiles": true}}),
|
||||
json!({"values": {"scripting": {"warnExecutingFiles": false}}}),
|
||||
] {
|
||||
let msg = refused(&mut s, "execute_command", json!({"command": "prefs.set", "params": params}));
|
||||
assert!(msg.contains("what a script may do"), "{msg}");
|
||||
}
|
||||
let got = call_json(&mut s, "execute_command", json!({"command": "prefs.get", "params": {"key": "scripting.allowScriptsWriteFiles"}}));
|
||||
assert_eq!(got, json!(false), "{got}");
|
||||
// Other settings, and looking at the script windows, still work.
|
||||
call_json(&mut s, "execute_command", json!({"command": "prefs.set", "params": {"key": "general.showToolTips", "value": false}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "scriptui.list"}));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
|
||||
|
||||
// ------------------------------------------------------------------ engine tools
|
||||
|
||||
/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`.
|
||||
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
|
||||
let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false);
|
||||
let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true));
|
||||
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
|
||||
// Drop empty fields to keep the listing compact.
|
||||
let list: Vec<Value> = r
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if compact {
|
||||
return json!({"id": c["id"], "label": c["label"]});
|
||||
}
|
||||
// Drop empty fields.
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}")));
|
||||
}
|
||||
@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("command.describe", json!({"command": id}))?)
|
||||
}
|
||||
|
||||
/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`.
|
||||
fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
|
||||
let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?;
|
||||
if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) {
|
||||
r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect();
|
||||
}
|
||||
json_reply(r)
|
||||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
@@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
static TOOLS: &[ToolDef] = &[
|
||||
ToolDef {
|
||||
name: "list_commands",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
|
||||
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
@@ -696,9 +707,17 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "list_effects",
|
||||
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.",
|
||||
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.",
|
||||
bridge_only: false,
|
||||
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."},
|
||||
"compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
},
|
||||
run: list_effects,
|
||||
},
|
||||
ToolDef {
|
||||
@@ -911,14 +930,15 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "command_list",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
|
||||
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`); `compact: true` lists only ids and labels.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."},
|
||||
"compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::harness::{Img, Qa, coverage, luma, mean_diff};
|
||||
|
||||
fn lum(i: &Img, x: u32, y: u32) -> f64 {
|
||||
luma(i.get_pixel(x, y))
|
||||
}
|
||||
|
||||
fn px(img: &Img, x: u32, y: u32) -> [u8; 4] {
|
||||
img.get_pixel(x, y).0
|
||||
}
|
||||
@@ -25,14 +29,13 @@ fn write_sequence(qa: &Qa) -> String {
|
||||
qa.path("seq/ball_000.png")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let mut qa = Qa::new("compositing");
|
||||
|
||||
/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the
|
||||
/// sequence item and the PSD's composition.
|
||||
fn import_footage(qa: &mut Qa) -> (Value, Value) {
|
||||
// Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
|
||||
// Photoshop Layers) from a small comp.
|
||||
let seq = write_sequence(&qa);
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq]}));
|
||||
let seq = write_sequence(qa);
|
||||
let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true}));
|
||||
let seq_item = imp["items"][0].clone();
|
||||
let project = qa.tool("get_project", json!({}));
|
||||
let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone();
|
||||
@@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()}));
|
||||
let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}");
|
||||
(seq_item, psd_comp)
|
||||
}
|
||||
|
||||
/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame.
|
||||
fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img {
|
||||
// The main comp.
|
||||
let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
|
||||
qa.exec("layer.addItem", json!({"item": seq_item}));
|
||||
qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}));
|
||||
qa.exec("layer.addItem", json!({"item": seq_item.clone()}));
|
||||
let a = qa.frame(None, 0.0);
|
||||
let b = qa.frame(None, 0.5);
|
||||
assert!(mean_diff(&a, &b) > 1.0, "the sequence plays");
|
||||
@@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
for c in 0..3 {
|
||||
assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}");
|
||||
}
|
||||
normal
|
||||
}
|
||||
|
||||
fn track_matte(qa: &mut Qa) {
|
||||
// Track matte: a magenta solid seen only through text.
|
||||
qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"}));
|
||||
qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90}));
|
||||
@@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone();
|
||||
assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte");
|
||||
qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false}));
|
||||
}
|
||||
|
||||
fn adjustment_layer(qa: &mut Qa, normal: &Img) {
|
||||
// Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
|
||||
qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
|
||||
let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
|
||||
@@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]}));
|
||||
assert!(mk["mask"].is_u64(), "{mk}");
|
||||
let hard = qa.frame(None, 0.5);
|
||||
let lum = |i: &Img, x, y| luma(i.get_pixel(x, y));
|
||||
assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse");
|
||||
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8}));
|
||||
let grown = qa.frame(None, 0.5);
|
||||
@@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
}
|
||||
n
|
||||
};
|
||||
assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area");
|
||||
assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area");
|
||||
qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20}));
|
||||
let soft = qa.frame(None, 0.5);
|
||||
// At the ellipse's edge (x = 40 − 8): feathered is in between none and full.
|
||||
@@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]}));
|
||||
let glow = qa.frame(None, 0.5);
|
||||
assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame");
|
||||
}
|
||||
|
||||
/// The 3D stage with depth of field and a light; returns the stage comp.
|
||||
fn stage_3d(qa: &mut Qa) -> Value {
|
||||
// 3D stage: a near and a far card, a camera with depth of field, a light.
|
||||
let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
|
||||
qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40}));
|
||||
@@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let advanced = qa.frame(None, 0.0);
|
||||
assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way");
|
||||
assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card");
|
||||
stage
|
||||
}
|
||||
|
||||
fn alpha_renders(qa: &mut Qa, stage: &Value) {
|
||||
// Renders with alpha: the stage has no background layer, so most of it is transparent.
|
||||
let mov = qa.path("stage.mov");
|
||||
let webm = qa.path("stage.webm");
|
||||
@@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}");
|
||||
let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false}));
|
||||
assert_eq!(reference["width"], 160);
|
||||
let refa = alpha_frame(&mut qa, stage.clone());
|
||||
let refa = alpha_frame(qa, stage.clone());
|
||||
assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45));
|
||||
// ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions
|
||||
// (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane.
|
||||
@@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
assert_eq!(r["errors"], json!([]));
|
||||
assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel");
|
||||
let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
|
||||
let f = alpha_frame(&mut qa, c.clone());
|
||||
let f = alpha_frame(qa, c.clone());
|
||||
// The opaque card decodes to the stage's colours (lossy codecs, so loosely).
|
||||
let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45));
|
||||
assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}");
|
||||
assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage");
|
||||
assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45));
|
||||
}
|
||||
let _ = main;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
|
||||
let mut qa = Qa::new("compositing");
|
||||
let (seq_item, psd_comp) = import_footage(&mut qa);
|
||||
let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp);
|
||||
track_matte(&mut qa);
|
||||
adjustment_layer(&mut qa, &normal);
|
||||
let stage = stage_3d(&mut qa);
|
||||
alpha_renders(&mut qa, &stage);
|
||||
}
|
||||
|
||||
fn alpha_frame(qa: &mut Qa, comp: Value) -> Img {
|
||||
|
||||
@@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) {
|
||||
|
||||
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
|
||||
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
|
||||
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
|
||||
let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone();
|
||||
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
|
||||
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
|
||||
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
|
||||
|
||||
@@ -4,8 +4,8 @@ Clean-room, pure-Rust AV1 encoder for EffectCraft's MP4 / WebM export (layer L0,
|
||||
no `unsafe`, builds for `wasm32-unknown-unknown`).
|
||||
|
||||
Output is decoded **bit-exactly** by libdav1d (`ffmpeg -c:v libdav1d`, checked in
|
||||
`tests/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
`tests/it/ffmpeg_oracle.rs` when ffmpeg is installed) and by FilmCraft's spec-derived
|
||||
`filmcraft-av1` decoder (`tests/it/roundtrip.rs`): every decoded frame equals the encoder's own
|
||||
reconstruction, so prediction never drifts.
|
||||
|
||||
## What is implemented
|
||||
@@ -48,8 +48,10 @@ reconstruction, so prediction never drifts.
|
||||
reconstruction loop; full coefficient coding (all_zero, tx type, eob_pt, eob_extra,
|
||||
coeff_base_eob, coeff_base, coeff_br, dc_sign, Golomb) with the exact contexts.
|
||||
- **Rate control**: `ConstantQ(base_q_idx)`; `Bitrate { kbps }` adapts `base_q_idx` per frame
|
||||
towards the average target (key frames get a lower quantizer and a larger share; the first
|
||||
key frame is searched in a few passes).
|
||||
towards the average target (key frames get a lower quantizer and a larger share of the budget of
|
||||
their group of `keyint` frames, the inter frames the rest; the first key frame is searched in a
|
||||
few passes, a later key frame that misses its budget by more than 19 % is coded again; the
|
||||
deviation from the budget is paid back over 16 frames).
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
//! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array
|
||||
//! holds N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
use crate::tables::*;
|
||||
use crate::tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF,
|
||||
DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF,
|
||||
DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF,
|
||||
DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF,
|
||||
DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF,
|
||||
DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF,
|
||||
DEFAULT_ZERO_MV_CDF,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Cdfs {
|
||||
|
||||
+228
-54
@@ -3,8 +3,12 @@
|
||||
//! processes, so the reconstruction built here is exactly what a decoder produces; the syntax
|
||||
//! is written afterwards by `writer.rs`.
|
||||
|
||||
use crate::pred::*;
|
||||
use crate::tables::*;
|
||||
use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, half_planes, predict_inter, predict_intra};
|
||||
use crate::tables::{
|
||||
AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM,
|
||||
NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA,
|
||||
};
|
||||
use crate::transform::{forward_2d, inverse_2d};
|
||||
|
||||
/// Decisions for one coded block.
|
||||
@@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize {
|
||||
if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT }
|
||||
}
|
||||
|
||||
/// Geometry and coding mode of one square transform block.
|
||||
#[derive(Clone, Copy)]
|
||||
struct TransformBlock {
|
||||
plane: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
log2: u32,
|
||||
tx_type: usize,
|
||||
intra: bool,
|
||||
}
|
||||
|
||||
struct TxResult {
|
||||
levels: Vec<i32>,
|
||||
nonzero: bool,
|
||||
@@ -107,6 +122,38 @@ struct TxResult {
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// The output and scratch buffers of one reconstruction, each `n * n` entries long.
|
||||
struct ReconBuffers<'a> {
|
||||
deq: &'a mut [i32],
|
||||
resid: &'a mut [i32],
|
||||
recon: &'a mut [u16],
|
||||
}
|
||||
|
||||
/// Outcome of trying one transform block; the levels and the reconstruction stay in `TxScratch`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct TxEval {
|
||||
nonzero: bool,
|
||||
dist: f64,
|
||||
bits: f64,
|
||||
}
|
||||
|
||||
/// Buffers of the transform path, sized for the largest coded transform block (32x32). Only the
|
||||
/// first `n * n` entries are used; they are reused across candidates instead of being allocated.
|
||||
struct TxScratch {
|
||||
res: [i32; 1024],
|
||||
coef: [f32; 1024],
|
||||
levels: [i32; 1024],
|
||||
deq: [i32; 1024],
|
||||
resid: [i32; 1024],
|
||||
recon: [u16; 1024],
|
||||
}
|
||||
|
||||
impl Default for TxScratch {
|
||||
fn default() -> Self {
|
||||
TxScratch { res: [0; 1024], coef: [0.0; 1024], levels: [0; 1024], deq: [0; 1024], resid: [0; 1024], recon: [0; 1024] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved encoder state of a block area (reconstruction, BlockDecoded flags).
|
||||
struct Snapshot {
|
||||
planes: [Vec<u16>; 3],
|
||||
@@ -127,13 +174,20 @@ pub(crate) struct Decider<'a> {
|
||||
ac_q: f32,
|
||||
dc_qi: i64,
|
||||
ac_qi: i64,
|
||||
/// Smallest magnitude that quantises to a non-zero level, `[dc, ac][intra]`.
|
||||
dead_zone: [[f32; 2]; 2],
|
||||
lambda: f64,
|
||||
lambda_sad: f64,
|
||||
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
|
||||
half: [Vec<u16>; 3],
|
||||
/// Transform buffers, taken out while a block is being tried (see `code_tx`).
|
||||
scratch: Option<Box<TxScratch>>,
|
||||
}
|
||||
|
||||
const MODE_BITS_INTRA: f64 = 3.0;
|
||||
/// Dead-zone quantiser rounding offsets (below one half) for inter and intra blocks.
|
||||
const INTER_RND: f32 = 0.30;
|
||||
const INTRA_RND: f32 = 0.38;
|
||||
|
||||
impl<'a> Decider<'a> {
|
||||
pub fn new(g: &'a FrameGeom, bd: u32, qidx: u8, intra_only: bool, src: &'a [Plane; 3], refr: Option<&'a RefFrame>) -> Self {
|
||||
@@ -160,12 +214,14 @@ impl<'a> Decider<'a> {
|
||||
ac_q: ac_qi as f32,
|
||||
dc_qi,
|
||||
ac_qi,
|
||||
dead_zone: [dc_qi, ac_qi].map(|q| [INTER_RND, INTRA_RND].map(|rnd| zero_threshold(q as f32, rnd))),
|
||||
lambda,
|
||||
lambda_sad: lambda.sqrt(),
|
||||
half: match refr {
|
||||
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
|
||||
Some(rf) => half_planes(&rf.planes[0], g.width, g.height, bd),
|
||||
None => Default::default(),
|
||||
},
|
||||
scratch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,8 +380,10 @@ impl<'a> Decider<'a> {
|
||||
let log2 = (n4 * 4).trailing_zeros();
|
||||
let (sbr, sbc) = ((r & 15) as isize, (c & 15) as isize);
|
||||
let bd = self.bd;
|
||||
let mut pred = vec![0u16; 32 * 32];
|
||||
let mut best_pred = vec![0u16; 32 * 32];
|
||||
let mut pred = [0u16; 32 * 32];
|
||||
let mut best_pred = [0u16; 32 * 32];
|
||||
let mut best_levels = [0i32; 32 * 32];
|
||||
let mut best_recon = [0u16; 32 * 32];
|
||||
let n = 1usize << log2;
|
||||
// luma
|
||||
let e = IntraEdge {
|
||||
@@ -343,26 +401,26 @@ impl<'a> Decider<'a> {
|
||||
let ed = edges(&self.rec[0], &e);
|
||||
// modes ranked by SATD, then a rate-distortion choice of mode and transform type among
|
||||
// the best three
|
||||
let mut ranked: Vec<(f64, usize)> = INTRA_MODES_USED
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
|
||||
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
|
||||
})
|
||||
.collect();
|
||||
let mut ranked: [(f64, usize); INTRA_MODES_USED.len()] = INTRA_MODES_USED.map(|mode| {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let sad = self.satd(0, c * 4, r * 4, n, &pred[..n * n]) as f64;
|
||||
(sad + self.lambda_sad * if mode == DC_PRED { 1.0 } else { MODE_BITS_INTRA }, mode)
|
||||
});
|
||||
ranked.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let tx_y = tx_size_for(log2);
|
||||
let mut best: Option<(f64, usize, usize, TxResult, Option<Vec<u16>>)> = None;
|
||||
let mut best: Option<(f64, usize, usize, TxEval, bool)> = None;
|
||||
let mut sc = self.scratch.take().unwrap_or_default();
|
||||
for &(_, mode) in ranked.iter().take(3) {
|
||||
predict_intra(&ed, &e, mode, &mut pred);
|
||||
let mut types = vec![DCT_DCT];
|
||||
let mut types = [DCT_DCT; 2];
|
||||
let mut n_types = 1;
|
||||
let alt = MODE_TO_TXFM[mode] as usize;
|
||||
if alt != DCT_DCT && tx_in_set(tx_set(tx_y, false), false, alt) {
|
||||
types.push(alt);
|
||||
types[1] = alt;
|
||||
n_types = 2;
|
||||
}
|
||||
for &t in &types {
|
||||
let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true);
|
||||
for &t in &types[..n_types] {
|
||||
let (res, has_recon) = self.try_tx(&mut sc, TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]);
|
||||
let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY {
|
||||
0.0
|
||||
} else if t == DCT_DCT {
|
||||
@@ -374,18 +432,24 @@ impl<'a> Decider<'a> {
|
||||
let cost = res.dist + self.lambda * (res.bits + type_bits + mode_bits);
|
||||
if best.as_ref().is_none_or(|b| cost < b.0) {
|
||||
best_pred[..n * n].copy_from_slice(&pred[..n * n]);
|
||||
best = Some((cost, mode, t, res, recon));
|
||||
best_levels[..n * n].copy_from_slice(&sc.levels[..n * n]);
|
||||
if has_recon {
|
||||
best_recon[..n * n].copy_from_slice(&sc.recon[..n * n]);
|
||||
}
|
||||
best = Some((cost, mode, t, res, has_recon));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.scratch = Some(sc);
|
||||
// Every ranked mode tries DCT_DCT, so `best` is always set; without a candidate the block
|
||||
// can't be coded intra (an infinite cost keeps it from being chosen).
|
||||
let Some((_, y_mode, tx_type_y, ty, recon)) = best else {
|
||||
let Some((_, y_mode, tx_type_y, ty, has_recon)) = best else {
|
||||
let leaf =
|
||||
Leaf { bsize, is_inter: false, y_mode: DC_PRED, uv_mode: DC_PRED, tx_type_y: DCT_DCT, mv: [0, 0], skip: true, coefs: Default::default() };
|
||||
return (leaf, f64::INFINITY);
|
||||
};
|
||||
self.put(0, c * 4, r * 4, n, recon.as_deref().unwrap_or(&best_pred[..n * n]));
|
||||
self.put(0, c * 4, r * 4, n, if has_recon { &best_recon[..n * n] } else { &best_pred[..n * n] });
|
||||
let ty = TxResult { levels: best_levels[..n * n].to_vec(), nonzero: ty.nonzero, dist: ty.dist, bits: ty.bits };
|
||||
let tx_type_y = if ty.nonzero { tx_type_y } else { DCT_DCT };
|
||||
self.mark_decoded(0, r, c, n4);
|
||||
// chroma (one mode for both planes)
|
||||
@@ -422,7 +486,7 @@ impl<'a> Decider<'a> {
|
||||
let uv_type = intra_uv_tx_type(uv_mode, uv_tx);
|
||||
let mut code_uv = |i: usize| {
|
||||
predict_intra(&ced[i], &ce[i], uv_mode, &mut pred);
|
||||
let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true);
|
||||
let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]);
|
||||
self.mark_decoded(1 + i, r, c, n4);
|
||||
t
|
||||
};
|
||||
@@ -450,13 +514,17 @@ impl<'a> Decider<'a> {
|
||||
let (vw, vh) = self.g.visible(p);
|
||||
let pn = n >> s;
|
||||
let (x, y) = ((c * 4) >> s, (r * 4) >> s);
|
||||
predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]);
|
||||
predict_inter(
|
||||
&refr.planes[p],
|
||||
&InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd },
|
||||
&mut pred[..pn * pn],
|
||||
);
|
||||
if bsize == BLOCK_64X64 {
|
||||
// coded as a skip block: the prediction is the reconstruction
|
||||
dist += self.sse(p, x, y, pn, &pred[..pn * pn]);
|
||||
self.put(p, x, y, pn, &pred[..pn * pn]);
|
||||
} else {
|
||||
let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false);
|
||||
let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]);
|
||||
dist += t.dist;
|
||||
tx.push(t);
|
||||
}
|
||||
@@ -515,8 +583,9 @@ impl<'a> Decider<'a> {
|
||||
let (dy, dx) = (m[0] >> 3, m[1] >> 3);
|
||||
dx >= lim_x.0 && dx <= lim_x.1 && dy >= lim_y.0 && dy <= lim_y.1
|
||||
};
|
||||
let lg = log2_table();
|
||||
let mv_bits = |m: [i32; 2]| -> f64 {
|
||||
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * ((d.abs() / 8) as f64 + 1.0).log2() };
|
||||
let comp = |d: i32| if d == 0 { 0.5 } else { 2.0 + 2.0 * log2_of(lg, (d.abs() / 8) as usize + 1) };
|
||||
comp(m[0] - pred_mv[0]) + comp(m[1] - pred_mv[1])
|
||||
};
|
||||
let cost =
|
||||
@@ -771,36 +840,48 @@ impl<'a> Decider<'a> {
|
||||
|
||||
/// Transform, quantise and reconstruct one square transform block of `plane` at (x, y)
|
||||
/// over the prediction `pred`; the reconstruction is written to `rec`.
|
||||
fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult {
|
||||
let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra);
|
||||
fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult {
|
||||
let TransformBlock { plane: p, x, y, log2, .. } = block;
|
||||
let mut sc = self.scratch.take().unwrap_or_default();
|
||||
let (res, has_recon) = self.try_tx(&mut sc, block, pred);
|
||||
let n = 1usize << log2;
|
||||
self.put(p, x, y, n, recon.as_deref().unwrap_or(pred));
|
||||
res
|
||||
self.put(p, x, y, n, if has_recon { &sc.recon[..n * n] } else { pred });
|
||||
let levels = sc.levels[..n * n].to_vec();
|
||||
self.scratch = Some(sc);
|
||||
TxResult { levels, nonzero: res.nonzero, dist: res.dist, bits: res.bits }
|
||||
}
|
||||
|
||||
/// Transform and quantise one block; returns the result and its reconstruction (None: the
|
||||
/// prediction) without writing it.
|
||||
fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option<Vec<u16>>) {
|
||||
/// Transform and quantise one block without writing it. The levels are left in `sc.levels`;
|
||||
/// the flag tells whether `sc.recon` holds the reconstruction (false: it is the prediction).
|
||||
fn try_tx(&self, sc: &mut TxScratch, block: TransformBlock, pred: &[u16]) -> (TxEval, bool) {
|
||||
let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block;
|
||||
let n = 1usize << log2;
|
||||
let nn = n * n;
|
||||
let tx_sz = tx_size_for(log2);
|
||||
let bd = self.bd;
|
||||
let src = &self.src[p];
|
||||
let mut res = vec![0i32; n * n];
|
||||
let TxScratch { res, coef, levels, deq, resid, recon } = sc;
|
||||
let (res, coef, levels) = (&mut res[..nn], &mut coef[..nn], &mut levels[..nn]);
|
||||
for i in 0..n {
|
||||
let row = &src.row(y + i)[x..x + n];
|
||||
for j in 0..n {
|
||||
res[i * n + j] = row[j] as i32 - pred[i * n + j] as i32;
|
||||
}
|
||||
}
|
||||
let mut coef = vec![0f32; n * n];
|
||||
forward_2d(&res, &mut coef, n, tx_type);
|
||||
forward_2d(res, coef, n, tx_type);
|
||||
// dead-zone quantiser: rounding offset below one half
|
||||
let rnd = if intra { 0.38f32 } else { 0.30f32 };
|
||||
let mut levels = vec![0i32; n * n];
|
||||
let rnd = if intra { INTRA_RND } else { INTER_RND };
|
||||
let [dc_thr, ac_thr] = [self.dead_zone[0][intra as usize], self.dead_zone[1][intra as usize]];
|
||||
levels.fill(0);
|
||||
let mut nonzero = false;
|
||||
for (k, (&cf, l)) in coef.iter().zip(levels.iter_mut()).enumerate() {
|
||||
let q = if k == 0 { self.dc_q } else { self.ac_q };
|
||||
let a = (cf.abs() / q + rnd).floor() as i32;
|
||||
let (q, thr) = if k == 0 { (self.dc_q, dc_thr) } else { (self.ac_q, ac_thr) };
|
||||
// dead zone: below the exact threshold the level is zero, no division needed
|
||||
if cf.abs() < thr {
|
||||
continue;
|
||||
}
|
||||
// the operand is >= 0, so truncation equals floor
|
||||
let a = (cf.abs() / q + rnd) as i32;
|
||||
if a != 0 {
|
||||
*l = if cf < 0.0 { -a.min(1 << 16) } else { a.min(1 << 16) };
|
||||
nonzero = true;
|
||||
@@ -808,23 +889,26 @@ impl<'a> Decider<'a> {
|
||||
}
|
||||
let zero_dist = self.sse(p, x, y, n, pred);
|
||||
if !nonzero {
|
||||
return (TxResult { levels, nonzero, dist: zero_dist, bits: 1.0 }, None);
|
||||
return (TxEval { nonzero, dist: zero_dist, bits: 1.0 }, false);
|
||||
}
|
||||
let recon = self.reconstruct(&levels, tx_sz, tx_type, pred, n, bd);
|
||||
let dist = self.sse(p, x, y, n, &recon);
|
||||
let bits = estimate_bits(&levels, tx_sz);
|
||||
let recon = &mut recon[..nn];
|
||||
self.reconstruct(levels, ReconBuffers { deq: &mut deq[..nn], resid: &mut resid[..nn], recon }, tx_sz, tx_type, pred, bd);
|
||||
let dist = self.sse(p, x, y, n, recon);
|
||||
let bits = estimate_bits(levels, tx_sz);
|
||||
if dist + self.lambda * bits >= zero_dist + self.lambda {
|
||||
levels.iter_mut().for_each(|l| *l = 0);
|
||||
return (TxResult { levels, nonzero: false, dist: zero_dist, bits: 1.0 }, None);
|
||||
levels.fill(0);
|
||||
return (TxEval { nonzero: false, dist: zero_dist, bits: 1.0 }, false);
|
||||
}
|
||||
(TxResult { levels, nonzero: true, dist, bits }, Some(recon))
|
||||
(TxEval { nonzero: true, dist, bits }, true)
|
||||
}
|
||||
|
||||
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction.
|
||||
fn reconstruct(&self, levels: &[i32], tx_sz: usize, tx_type: usize, pred: &[u16], n: usize, bd: u32) -> Vec<u16> {
|
||||
/// Dequantisation (7.12.3) and the inverse transform, added to the prediction; the result is
|
||||
/// written to `recon`. `deq` and `resid` are scratch buffers of `n * n` entries.
|
||||
fn reconstruct(&self, levels: &[i32], bufs: ReconBuffers, tx_sz: usize, tx_type: usize, pred: &[u16], bd: u32) {
|
||||
let ReconBuffers { deq, resid, recon } = bufs;
|
||||
let denom: i64 = if tx_sz == TX_32X32 { 2 } else { 1 };
|
||||
let lim = 1i64 << (7 + bd);
|
||||
let mut deq = vec![0i32; n * n];
|
||||
deq.fill(0);
|
||||
for (k, (&l, d)) in levels.iter().zip(deq.iter_mut()).enumerate() {
|
||||
if l == 0 {
|
||||
continue;
|
||||
@@ -834,13 +918,41 @@ impl<'a> Decider<'a> {
|
||||
let mag = (dq.abs() & 0xFF_FFFF) / denom;
|
||||
*d = (if dq < 0 { -mag } else { mag }).clamp(-lim, lim - 1) as i32;
|
||||
}
|
||||
let mut resid = vec![0i32; n * n];
|
||||
inverse_2d(&deq, &mut resid, tx_sz, tx_type, bd);
|
||||
inverse_2d(deq, resid, tx_sz, tx_type, bd);
|
||||
let max = (1i32 << bd) - 1;
|
||||
pred.iter().zip(&resid).map(|(&p, &r)| (p as i32 + r).clamp(0, max) as u16).collect()
|
||||
for ((o, &p), &r) in recon.iter_mut().zip(pred).zip(resid.iter()) {
|
||||
*o = (p as i32 + r).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest `x >= 0` with `(x / q + rnd) as i32 >= 1` in f32 arithmetic. The expression is
|
||||
/// monotone in `x`, so a binary search over the bit patterns finds the exact boundary.
|
||||
fn zero_threshold(q: f32, rnd: f32) -> f32 {
|
||||
let (mut lo, mut hi) = (0u32, f32::MAX.to_bits());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if (f32::from_bits(mid) / q + rnd) as i32 >= 1 {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
f32::from_bits(lo)
|
||||
}
|
||||
|
||||
const LOG2_TABLE_LEN: usize = 1025;
|
||||
|
||||
/// `log2(i)` for `i < LOG2_TABLE_LEN`, computed with the same `f64::log2` call as the direct form.
|
||||
fn log2_table() -> &'static [f64; LOG2_TABLE_LEN] {
|
||||
static TABLE: std::sync::OnceLock<[f64; LOG2_TABLE_LEN]> = std::sync::OnceLock::new();
|
||||
TABLE.get_or_init(|| std::array::from_fn(|i| (i as f64).log2()))
|
||||
}
|
||||
|
||||
fn log2_of(table: &[f64; LOG2_TABLE_LEN], v: usize) -> f64 {
|
||||
if v < LOG2_TABLE_LEN { table[v] } else { (v as f64).log2() }
|
||||
}
|
||||
|
||||
/// Unnormalised Walsh-Hadamard transform of 4 or 8 values, in place.
|
||||
fn hadamard(v: &mut [i32]) {
|
||||
let n = v.len();
|
||||
@@ -869,13 +981,14 @@ fn estimate_bits(levels: &[i32], tx_sz: usize) -> f64 {
|
||||
if eob == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let mut bits = 3.0 + (eob as f64).log2();
|
||||
let lg = log2_table();
|
||||
let mut bits = 3.0 + log2_of(lg, eob);
|
||||
for &pos in &scan[..eob] {
|
||||
let a = levels[pos as usize].unsigned_abs();
|
||||
bits += match a {
|
||||
0 => 0.7,
|
||||
1 => 2.5,
|
||||
_ => 3.5 + 1.6 * (a as f64).log2(),
|
||||
_ => 3.5 + 1.6 * log2_of(lg, a as usize),
|
||||
};
|
||||
}
|
||||
bits
|
||||
@@ -889,3 +1002,64 @@ pub(crate) fn scan_for(tx_sz: usize) -> &'static [u16] {
|
||||
_ => &DEFAULT_SCAN_32X32,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tables::{AC_QLOOKUP, DC_QLOOKUP};
|
||||
|
||||
/// The quantiser as it was before the dead-zone threshold and the truncation.
|
||||
fn quant_ref(cf: f32, q: f32, rnd: f32) -> i32 {
|
||||
(cf.abs() / q + rnd).floor() as i32
|
||||
}
|
||||
|
||||
fn quant_new(cf: f32, q: f32, rnd: f32, thr: f32) -> i32 {
|
||||
if cf.abs() < thr {
|
||||
return 0;
|
||||
}
|
||||
(cf.abs() / q + rnd) as i32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_zone_threshold_is_exact() {
|
||||
let mut state = 0x1234_5678_9abc_def0u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for table in AC_QLOOKUP.iter().chain(DC_QLOOKUP.iter()) {
|
||||
for &qi in table.iter() {
|
||||
let q = qi as f32;
|
||||
for rnd in [INTER_RND, INTRA_RND] {
|
||||
let thr = zero_threshold(q, rnd);
|
||||
// the boundary itself and its neighbours
|
||||
let b = thr.to_bits();
|
||||
for bits in b.saturating_sub(3)..=b + 3 {
|
||||
let cf = f32::from_bits(bits);
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr), "q {q} rnd {rnd} cf {cf}");
|
||||
assert_eq!(quant_ref(-cf, q, rnd), quant_new(-cf, q, rnd, thr));
|
||||
}
|
||||
for _ in 0..200 {
|
||||
let cf = f32::from_bits((next() >> 32) as u32 & 0x7fff_ffff);
|
||||
if cf.is_nan() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
|
||||
let cf = (next() % 100_000) as f32 / 100.0;
|
||||
assert_eq!(quant_ref(cf, q, rnd), quant_new(cf, q, rnd, thr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log2_table_matches_direct_log2() {
|
||||
let t = log2_table();
|
||||
for v in 1..3000usize {
|
||||
assert_eq!(log2_of(t, v).to_bits(), (v as f64).log2().to_bits(), "{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-25
@@ -24,6 +24,7 @@ use bitw::{BitWriter, obu};
|
||||
use decide::{Decider, FrameGeom, RefFrame};
|
||||
use loopfilter::{LfInfo, loop_filter};
|
||||
use pred::Plane;
|
||||
use tables::AC_QLOOKUP;
|
||||
use writer::TileWriter;
|
||||
|
||||
/// Rate control.
|
||||
@@ -136,10 +137,118 @@ struct RateState {
|
||||
frame_bits: f64,
|
||||
/// Quantizer index for inter frames (continuous).
|
||||
q: f64,
|
||||
/// Key frame quantizer index minus the inter frame one.
|
||||
key_off: f64,
|
||||
/// Quantizer index steps per halving of the frame size, measured from consecutive inter
|
||||
/// frames (the size/quantizer curve is much steeper than the default on noisy content).
|
||||
slope: f64,
|
||||
/// Quantizer index and size (in bits) of the previous inter frame.
|
||||
prev_inter: Option<(f64, f64)>,
|
||||
/// Accumulated (spent - budget) bits.
|
||||
debt: f64,
|
||||
}
|
||||
|
||||
/// Quantizer index steps per halving of the frame size before any measurement.
|
||||
const RC_SLOPE: f64 = 28.0;
|
||||
/// Fraction of the quantizer step the size error asks for that is applied per frame.
|
||||
const RC_GAIN: f64 = 0.8;
|
||||
/// Frames over which an accumulated deviation from the budget is paid back.
|
||||
const RC_PAYBACK: f64 = 16.0;
|
||||
|
||||
impl RateState {
|
||||
/// The nominal bit budget and the starting quantizer index of a key or inter frame. A key
|
||||
/// frame gets a larger share, and the inter frames of the group get the rest, so that a whole
|
||||
/// group of `keyint` frames adds up to `keyint * frame_bits`.
|
||||
fn frame_budget(&self, keyint: u32, key: bool) -> (f64, f64) {
|
||||
let keyint = keyint.max(1) as f64;
|
||||
let boost = (1.0 + (keyint.clamp(1.0, 60.0) - 1.0).sqrt() * 0.75).min(0.5 * keyint + 0.5);
|
||||
if key {
|
||||
(self.frame_bits * boost, (self.q + self.key_off).clamp(1.0, 255.0))
|
||||
} else if keyint > 1.0 {
|
||||
(self.frame_bits * (keyint - boost) / (keyint - 1.0), self.q)
|
||||
} else {
|
||||
(self.frame_bits, self.q)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts the quantizers to the size of the frame just coded at `q_used` against `target`.
|
||||
fn update(&mut self, key: bool, first: bool, q_used: f64, bits: f64, target: f64) {
|
||||
let err = (bits / target).log2().clamp(-1.5, 1.5);
|
||||
if first && key {
|
||||
// four passes ran: the key frame quantizer is known, the inter one follows from the offset
|
||||
self.q = (q_used - self.key_off).clamp(1.0, 255.0);
|
||||
} else if key {
|
||||
self.key_off = q_used - self.q + RC_GAIN * self.slope * err;
|
||||
} else {
|
||||
if let Some((pq, pbits)) = self.prev_inter {
|
||||
let (dq, dr) = (q_used - pq, (pbits / bits).log2());
|
||||
if dq.abs() >= 3.0 && dr.abs() >= 0.15 && dq * dr > 0.0 {
|
||||
self.slope = (0.6 * self.slope + 0.4 * (dq / dr)).clamp(3.0, 45.0);
|
||||
}
|
||||
}
|
||||
self.prev_inter = Some((q_used, bits));
|
||||
self.q = (q_used + RC_GAIN * self.slope * err).clamp(1.0, 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most passes on the first frame, and how far from the target (octaves, 3 %) its size may be
|
||||
/// before it is accepted.
|
||||
const RC_FIRST_PASSES: usize = 4;
|
||||
const RC_FIRST_TOLERANCE: f64 = 0.04;
|
||||
/// Quantizer index steps per halving of the size, for the first correction of the first frame
|
||||
/// (the starting quantizer comes from the bits per sample; the first pass shows how far off it
|
||||
/// is, and from there on the passes are placed by `next_probe_q`).
|
||||
const RC_FIRST_SLOPE: f64 = 30.0;
|
||||
/// The same for a key frame after the first: coded again when its size is more than 0.25 octaves
|
||||
/// (19 %) off the target.
|
||||
const RC_KEY_PASSES: usize = 3;
|
||||
const RC_KEY_TOLERANCE: f64 = 0.25;
|
||||
/// Octaves of frame size per octave of quantizer step assumed for the first correction of a frame.
|
||||
const RC_PROBE_SLOPE: f64 = 1.0;
|
||||
/// Largest change of the quantizer step (octaves) between two passes on one frame.
|
||||
const RC_PROBE_MAX_STEP: f64 = 2.0;
|
||||
|
||||
/// The quantizer index whose step size is closest to `2^x`.
|
||||
fn q_for_step(bit_depth: u8, x: f64) -> u8 {
|
||||
let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
|
||||
let mut best = 1;
|
||||
for q in 1..=255usize {
|
||||
if ((table[q] as f64).log2() - x).abs() < ((table[best] as f64).log2() - x).abs() {
|
||||
best = q;
|
||||
}
|
||||
}
|
||||
best as u8
|
||||
}
|
||||
|
||||
/// The next quantizer to try for a frame, from the `(quantizer, log2(size / target))`
|
||||
/// pairs coded so far. The size follows the quantizer step (not the index, which is linear in the
|
||||
/// step only below about 60) in a near log-log line: false position between the closest pair of
|
||||
/// passes that enclose the target, otherwise a step from the latest one with the slope of the
|
||||
/// last two. `None` when no untried quantizer lies between the enclosing passes.
|
||||
fn next_probe_q(bit_depth: u8, tried: &[(u8, f64)]) -> Option<u8> {
|
||||
let table = &AC_QLOOKUP[((bit_depth - 8) / 2) as usize];
|
||||
let x = |q: u8| (table[q as usize] as f64).log2();
|
||||
let over = tried.iter().filter(|t| t.1 > 0.0).max_by_key(|t| t.0);
|
||||
let under = tried.iter().filter(|t| t.1 <= 0.0).min_by_key(|t| t.0);
|
||||
if let (Some(&(q_lo, e_lo)), Some(&(q_hi, e_hi))) = (over, under) {
|
||||
if q_hi - q_lo < 2 {
|
||||
return None;
|
||||
}
|
||||
let next = x(q_lo) + (x(q_hi) - x(q_lo)) * e_lo / (e_lo - e_hi);
|
||||
return Some(q_for_step(bit_depth, next).clamp(q_lo + 1, q_hi - 1));
|
||||
}
|
||||
let &(q, err) = tried.last()?;
|
||||
let mut slope = RC_PROBE_SLOPE;
|
||||
if let [.., (q0, e0), (q1, e1)] = tried
|
||||
&& (x(*q1) - x(*q0)).abs() > 0.05
|
||||
&& (e0 - e1) / (x(*q1) - x(*q0)) > 0.0
|
||||
{
|
||||
slope = ((e0 - e1) / (x(*q1) - x(*q0))).clamp(0.25, 3.0);
|
||||
}
|
||||
Some(q_for_step(bit_depth, x(q) + (err / slope).clamp(-RC_PROBE_MAX_STEP, RC_PROBE_MAX_STEP)))
|
||||
}
|
||||
|
||||
/// AV1 encoder. Frames come out in input order (no hidden/reordered frames).
|
||||
pub struct Encoder {
|
||||
cfg: EncoderConfig,
|
||||
@@ -185,7 +294,7 @@ impl Encoder {
|
||||
// initial guess from the bits per sample
|
||||
let bpp = frame_bits / (cfg.width as f64 * cfg.height as f64);
|
||||
let q = (110.0 - 40.0 * (bpp / 0.1).log2()).clamp(20.0, 230.0);
|
||||
Some(RateState { frame_bits, q, debt: 0.0 })
|
||||
Some(RateState { frame_bits, q, key_off: -24.0, slope: RC_SLOPE, prev_inter: None, debt: 0.0 })
|
||||
}
|
||||
};
|
||||
let mut enc = Encoder { cfg, geom, level, seq_obu: Vec::new(), frame_num: 0, refr: None, rc };
|
||||
@@ -409,11 +518,13 @@ impl Encoder {
|
||||
};
|
||||
let bd = self.cfg.bit_depth as u32;
|
||||
let mut cache: Vec<(u32, u64)> = vec![(0, sse(&rec[0]))];
|
||||
// one working copy of the luma plane for all candidate levels
|
||||
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
|
||||
let mut eval = |lvl: u32| -> u64 {
|
||||
if let Some(&(_, e)) = cache.iter().find(|(l, _)| *l == lvl) {
|
||||
return e;
|
||||
}
|
||||
let mut planes = [rec[0].clone(), Plane::new(0, 0), Plane::new(0, 0)];
|
||||
planes[0].data.copy_from_slice(&rec[0].data);
|
||||
loop_filter(&mut planes, info, [lvl; 4], bd, true, sb_row_step);
|
||||
let e = sse(&planes[0]);
|
||||
cache.push((lvl, e));
|
||||
@@ -469,38 +580,52 @@ impl Encoder {
|
||||
|
||||
fn encode_rate_controlled(&mut self, src: &[Plane; 3], key: bool, rc: &mut RateState) -> (Vec<u8>, RefFrame) {
|
||||
let first = self.frame_num == 0;
|
||||
let (frame_bits, debt, q_inter) = (rc.frame_bits, rc.debt, rc.q);
|
||||
// key frames get a lower quantizer and a larger share of the budget
|
||||
let key_boost = 1.0 + (self.cfg.keyint.clamp(1, 60) as f64 - 1.0).sqrt() * 0.75;
|
||||
let budget = if key { frame_bits * key_boost } else { frame_bits };
|
||||
// pay back a fraction of the accumulated deviation
|
||||
let target = (budget - debt * 0.25).max(budget * 0.25);
|
||||
let mut q = if key { q_inter - 24.0 } else { q_inter };
|
||||
let mut best: Option<(Vec<u8>, RefFrame, f64)> = None;
|
||||
let passes = if first { 4 } else { 1 };
|
||||
for pass in 0..passes {
|
||||
let (budget, q_start) = rc.frame_budget(self.cfg.keyint, key);
|
||||
// pay back the accumulated deviation over the next RC_PAYBACK frames
|
||||
let target = (budget - rc.debt / RC_PAYBACK).clamp(budget * 0.35, budget * 2.0);
|
||||
let mut q = q_start;
|
||||
let mut best: Option<(Vec<u8>, RefFrame, f64, f64)> = None;
|
||||
// The first frame is coded up to four times to find its quantizer (it fixes the
|
||||
// quantizers of everything after it), a later key frame up to three times (it is a large
|
||||
// part of the budget of its group). A frame is coded again only while its size is far from
|
||||
// the target and an untried quantizer is left to try; other frames are coded once.
|
||||
let (max_passes, tolerance) = if first {
|
||||
(RC_FIRST_PASSES, RC_FIRST_TOLERANCE)
|
||||
} else if key {
|
||||
(RC_KEY_PASSES, RC_KEY_TOLERANCE)
|
||||
} else {
|
||||
(1, 0.0)
|
||||
};
|
||||
let mut tried: Vec<(u8, f64)> = Vec::new();
|
||||
for _ in 0..max_passes {
|
||||
let qi = q.round().clamp(1.0, 255.0) as u8;
|
||||
if tried.iter().any(|t| t.0 == qi) {
|
||||
break;
|
||||
}
|
||||
let (obu, rf) = self.encode_frame(src, key, qi);
|
||||
let bits = obu.len() as f64 * 8.0;
|
||||
let err = (bits / target).log2();
|
||||
let better = best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs());
|
||||
if better {
|
||||
best = Some((obu, rf, bits));
|
||||
tried.push((qi, err));
|
||||
if best.as_ref().is_none_or(|b| err.abs() < (b.2 / target).log2().abs()) {
|
||||
best = Some((obu, rf, bits, qi as f64));
|
||||
}
|
||||
if pass + 1 < passes {
|
||||
q = (q + 30.0 * err).clamp(1.0, 255.0);
|
||||
if err.abs() <= tolerance {
|
||||
break;
|
||||
}
|
||||
let next = if first && tried.len() == 1 {
|
||||
Some((q + RC_FIRST_SLOPE * err).clamp(1.0, 255.0))
|
||||
} else {
|
||||
next_probe_q(self.cfg.bit_depth, &tried).map(f64::from)
|
||||
};
|
||||
match next {
|
||||
Some(next) => q = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// At least one pass ran, so `best` is set.
|
||||
let Some((obu, rf, bits)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
|
||||
let Some((obu, rf, bits, q_used)) = best else { return self.encode_frame(src, key, q.round().clamp(1.0, 255.0) as u8) };
|
||||
rc.debt += bits - budget;
|
||||
// adapt the inter quantizer: about 30 index steps per doubling of the rate
|
||||
let err = (bits / target).log2().clamp(-1.0, 1.0);
|
||||
if first && key {
|
||||
rc.q = (q + 24.0).clamp(1.0, 255.0);
|
||||
} else {
|
||||
rc.q = (rc.q + 12.0 * err).clamp(1.0, 255.0);
|
||||
}
|
||||
rc.update(key, first, q_used, bits, target);
|
||||
(obu, rf)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! is 0 in the frames this encoder writes.
|
||||
|
||||
use crate::pred::Plane;
|
||||
use crate::tables::*;
|
||||
use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH};
|
||||
|
||||
/// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the
|
||||
/// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`).
|
||||
@@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
|
||||
}
|
||||
let mut col = 0;
|
||||
while col < info.mi_cols {
|
||||
edge(pl, info, level, bit_depth, plane, pass, row, col);
|
||||
edge(pl, info, level, bit_depth, plane, pass, (row, col));
|
||||
col += step;
|
||||
}
|
||||
row += step;
|
||||
@@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4
|
||||
}
|
||||
}
|
||||
|
||||
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) {
|
||||
fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) {
|
||||
let (row, col) = position;
|
||||
let s = (plane > 0) as usize;
|
||||
let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) };
|
||||
let x = col * 4;
|
||||
@@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p
|
||||
let limit = lvl.max(1);
|
||||
let blimit = 2 * (lvl + 2) + limit;
|
||||
let thresh = lvl >> 4;
|
||||
let strength = FilterStrength { limit, blimit, thresh };
|
||||
for k in 0..4 {
|
||||
sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd);
|
||||
sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thresholds derived from the loop-filter level (spec 7.14.4).
|
||||
struct FilterStrength {
|
||||
limit: i32,
|
||||
blimit: i32,
|
||||
thresh: i32,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) {
|
||||
fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) {
|
||||
let (x, y) = position;
|
||||
let (dx, dy) = direction;
|
||||
let FilterStrength { limit, blimit, thresh } = *strength;
|
||||
let stride = pl.stride as isize;
|
||||
let step = dy * stride + dx;
|
||||
let base = y as isize * stride + x as isize;
|
||||
|
||||
+99
-16
@@ -2,7 +2,10 @@
|
||||
//! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without
|
||||
//! reference scaling) so that the encoder predicts exactly like a decoder.
|
||||
|
||||
use crate::tables::*;
|
||||
use crate::tables::{
|
||||
DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED,
|
||||
};
|
||||
use crate::transform::round2;
|
||||
|
||||
/// A plane buffer (padded to whole superblocks).
|
||||
@@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [
|
||||
/// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by
|
||||
/// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample
|
||||
/// coordinates of the reference plane; the result goes to `pred` (row stride `w`).
|
||||
pub(crate) fn predict_inter(
|
||||
refp: &Plane,
|
||||
last_x: i32,
|
||||
last_y: i32,
|
||||
x: usize,
|
||||
y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
mv: [i32; 2],
|
||||
ss: usize,
|
||||
bit_depth: u32,
|
||||
pred: &mut [u16],
|
||||
) {
|
||||
pub(crate) struct InterBlock {
|
||||
pub last_x: i32,
|
||||
pub last_y: i32,
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub mv: [i32; 2],
|
||||
pub subsampling: usize,
|
||||
pub bit_depth: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) {
|
||||
let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block;
|
||||
// positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase)
|
||||
let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss);
|
||||
let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss);
|
||||
@@ -293,7 +297,8 @@ pub(crate) fn predict_inter(
|
||||
|
||||
/// The visible `w x h` area of a reference plane interpolated at the sub-sample phase
|
||||
/// (`fx`, `fy`) (1/16 units) with the EIGHTTAP filter, as `predict_inter` computes it for
|
||||
/// blocks wider and taller than 4. Used for motion search.
|
||||
/// blocks wider and taller than 4. The reference for `half_planes`, which the encoder uses.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usize, bit_depth: u32) -> Vec<u16> {
|
||||
let kh = &SUBPEL_FILTERS[EIGHTTAP][fx];
|
||||
let kv = &SUBPEL_FILTERS[EIGHTTAP][fy];
|
||||
@@ -323,6 +328,66 @@ pub(crate) fn subpel_plane(refp: &Plane, w: usize, h: usize, fx: usize, fy: usiz
|
||||
out
|
||||
}
|
||||
|
||||
/// The three half-sample planes (8, 0), (0, 8), (8, 8) of `subpel_plane` in one pass: the
|
||||
/// horizontal half-sample pass is shared by (8, 0) and (8, 8), and the phase-0 kernels reduce to
|
||||
/// exact shifts (Round2( 128 * v, 11 ) = Round2( v, 4 ), Round2( 16 * s, 11 ) = Round2( s, 7 )),
|
||||
/// so the result equals three `subpel_plane` calls bit for bit.
|
||||
pub(crate) fn half_planes(refp: &Plane, w: usize, h: usize, bit_depth: u32) -> [Vec<u16>; 3] {
|
||||
let k = &SUBPEL_FILTERS[EIGHTTAP][8];
|
||||
let max = (1i32 << bit_depth) - 1;
|
||||
let (lx, ly) = (w as i32 - 1, h as i32 - 1);
|
||||
// horizontal pass at phase 8: Round2( sum, 3 )
|
||||
let mut hp = vec![0i32; w * h];
|
||||
for y in 0..h {
|
||||
let row = &refp.row(y)[..w];
|
||||
let out = &mut hp[y * w..y * w + w];
|
||||
let tap = |x: i32| -> i32 {
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * row[(x + t as i32 - 3).clamp(0, lx) as usize] as i32;
|
||||
}
|
||||
round2(s, 3)
|
||||
};
|
||||
if w >= 8 {
|
||||
for x in 0..3 {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
for (x, o) in out.iter_mut().enumerate().take(w - 4).skip(3) {
|
||||
let seg = &row[x - 3..x + 5];
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += k[t] as i32 * seg[t] as i32;
|
||||
}
|
||||
*o = round2(s, 3);
|
||||
}
|
||||
for x in w - 4..w {
|
||||
out[x] = tap(x as i32);
|
||||
}
|
||||
} else {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = tap(x as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let p80: Vec<u16> = hp.iter().map(|&v| round2(v, 4).clamp(0, max) as u16).collect();
|
||||
let mut p08 = vec![0u16; w * h];
|
||||
let mut p88 = vec![0u16; w * h];
|
||||
for y in 0..h {
|
||||
let rows: [usize; 8] = std::array::from_fn(|t| (y as i32 + t as i32 - 3).clamp(0, ly) as usize);
|
||||
let (o08, o88) = (&mut p08[y * w..y * w + w], &mut p88[y * w..y * w + w]);
|
||||
for x in 0..w {
|
||||
let (mut a, mut b) = (0i32, 0i32);
|
||||
for t in 0..8 {
|
||||
a += k[t] as i32 * refp.data[rows[t] * refp.stride + x] as i32;
|
||||
b += k[t] as i32 * hp[rows[t] * w + x];
|
||||
}
|
||||
o08[x] = round2(a, 7).clamp(0, max) as u16;
|
||||
o88[x] = round2(b, 11).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
[p80, p08, p88]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -340,7 +405,11 @@ mod tests {
|
||||
for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] {
|
||||
let mut pred = vec![0u16; 64];
|
||||
let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2];
|
||||
predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred);
|
||||
predict_inter(
|
||||
&p,
|
||||
&InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 },
|
||||
&mut pred,
|
||||
);
|
||||
for r in 0..8 {
|
||||
for c in 0..8 {
|
||||
let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1));
|
||||
@@ -353,6 +422,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_planes_match_subpel_plane() {
|
||||
for (w, h) in [(37usize, 29usize), (5, 3), (1, 1), (64, 64)] {
|
||||
let mut p = Plane::new(64, 64);
|
||||
for (i, v) in p.data.iter_mut().enumerate() {
|
||||
*v = ((i * 7919) % 1024) as u16;
|
||||
}
|
||||
let hp = half_planes(&p, w, h, 10);
|
||||
for (i, (fx, fy)) in [(8, 0), (0, 8), (8, 8)].into_iter().enumerate() {
|
||||
assert_eq!(hp[i], subpel_plane(&p, w, h, fx, fy, 10), "{w}x{h} phase ({fx}, {fy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_zero_filters_are_impulses() {
|
||||
for f in SUBPEL_FILTERS.iter() {
|
||||
|
||||
+100
-10
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::tables::*;
|
||||
use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2};
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
@@ -32,6 +32,7 @@ fn sin128(angle: i32) -> i64 {
|
||||
cos128(angle - 64)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn brev(num_bits: u32, x: usize) -> usize {
|
||||
let mut t = 0;
|
||||
for i in 0..num_bits {
|
||||
@@ -63,14 +64,24 @@ fn hd(t: &mut [i32], a: usize, b: usize, flip: bool, r: u32) {
|
||||
t[b] = (x - y).clamp(lo, hi);
|
||||
}
|
||||
|
||||
/// Inverse DCT of length 2^n, n = 2..=5 (7.13.2.3), in place.
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
/// Inverse DCT of length 2^N, N = 2..=5 (7.13.2.3), in place. `N` is a const generic so that the
|
||||
/// bit-reversal permutations and loop bounds fold at compile time.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_n<const N: u32>(t: &mut [i32], r: u32) {
|
||||
let n = N;
|
||||
let len = 1usize << n;
|
||||
let mut copy = [0i32; 32];
|
||||
copy[..len].copy_from_slice(&t[..len]);
|
||||
for i in 0..len {
|
||||
t[i] = copy[brev(n, i)];
|
||||
}
|
||||
inverse_dct_initial_stages(t, n, r);
|
||||
inverse_dct_final_stages(t, n, r);
|
||||
}
|
||||
|
||||
/// Rotate the high-frequency branches before the four-point core.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..8 {
|
||||
bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false);
|
||||
@@ -103,6 +114,11 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the four-point core and merge progressively larger branches.
|
||||
#[inline(always)]
|
||||
fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
}
|
||||
@@ -180,6 +196,7 @@ const SINPI_2_9: i64 = 2482;
|
||||
const SINPI_3_9: i64 = 3344;
|
||||
const SINPI_4_9: i64 = 3803;
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst4(t: &mut [i32]) {
|
||||
let (t0, t1, t2, t3) = (t[0] as i64, t[1] as i64, t[2] as i64, t[3] as i64);
|
||||
let mut s = [SINPI_1_9 * t0, SINPI_2_9 * t0, SINPI_3_9 * t1, SINPI_4_9 * t2, SINPI_1_9 * t2, SINPI_2_9 * t3, SINPI_4_9 * t3];
|
||||
@@ -197,7 +214,9 @@ fn inverse_adst4(t: &mut [i32]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_in_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -207,7 +226,9 @@ fn adst_in_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
#[inline(always)]
|
||||
fn adst_out_permute<const N: u32>(t: &mut [i32]) {
|
||||
let n = N;
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [0i32; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
@@ -221,8 +242,9 @@ fn adst_out_permute(t: &mut [i32], n: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 3);
|
||||
adst_in_permute::<3>(t);
|
||||
for i in 0..4 {
|
||||
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
|
||||
}
|
||||
@@ -240,11 +262,12 @@ fn inverse_adst8(t: &mut [i32], r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 3);
|
||||
adst_out_permute::<3>(t);
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
adst_in_permute(t, 4);
|
||||
adst_in_permute::<4>(t);
|
||||
for i in 0..8 {
|
||||
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
|
||||
}
|
||||
@@ -273,7 +296,7 @@ fn inverse_adst16(t: &mut [i32], r: u32) {
|
||||
for i in 0..4 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 4);
|
||||
adst_out_permute::<4>(t);
|
||||
}
|
||||
|
||||
/// 1-D transform kind.
|
||||
@@ -298,6 +321,17 @@ pub(crate) fn kinds(tx_type: usize) -> (Kind, Kind) {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
|
||||
match n {
|
||||
2 => inverse_dct_n::<2>(t, r),
|
||||
3 => inverse_dct_n::<3>(t, r),
|
||||
4 => inverse_dct_n::<4>(t, r),
|
||||
_ => inverse_dct_n::<5>(t, r),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
match kind {
|
||||
Kind::Dct => inverse_dct(t, n, r),
|
||||
@@ -312,7 +346,17 @@ fn inverse_1d(t: &mut [i32], kind: Kind, n: u32, r: u32) {
|
||||
/// 2-D inverse transform (7.13.3) of a square `n x n` block (n = 4..=32) of dequantised
|
||||
/// coefficients (row stride `n`) into `residual` (row stride `n`).
|
||||
pub(crate) fn inverse_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = TX_WIDTH_LOG2[tx_sz] as u32;
|
||||
match TX_WIDTH_LOG2[tx_sz] {
|
||||
2 => inverse_2d_n::<2>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
3 => inverse_2d_n::<3>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
4 => inverse_2d_n::<4>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
_ => inverse_2d_n::<5>(dequant, residual, tx_sz, tx_type, bit_depth),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inverse_2d` for a block of width `1 << LOG2`.
|
||||
fn inverse_2d_n<const LOG2: u32>(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, bit_depth: u32) {
|
||||
let log2 = LOG2;
|
||||
let n = 1usize << log2;
|
||||
let row_shift = TRANSFORM_ROW_SHIFT[tx_sz] as u32;
|
||||
let row_clamp = bit_depth + 8;
|
||||
@@ -419,6 +463,51 @@ fn basis(kind: Kind, n: usize) -> &'static [f32] {
|
||||
/// Forward 2-D transform of the `n x n` residual (row stride `n`) into orthonormal-domain
|
||||
/// coefficients scaled by 8 (so that `level = out / q` with the spec's dequantiser).
|
||||
pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
match n {
|
||||
4 => forward_2d_n::<4, 16>(residual, out, tx_type),
|
||||
8 => forward_2d_n::<8, 64>(residual, out, tx_type),
|
||||
16 => forward_2d_n::<16, 256>(residual, out, tx_type),
|
||||
_ => forward_2d_dyn(residual, out, n, tx_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// `forward_2d` for a fixed size, so `tmp` is exactly `NN = N * N` floats (no 4 KiB zeroing).
|
||||
#[inline(always)]
|
||||
fn forward_2d_n<const N: usize, const NN: usize>(residual: &[i32], out: &mut [f32], tx_type: usize) {
|
||||
let n = N;
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = &basis(ck, n)[..NN];
|
||||
let brt = &basis_t(rk, n)[..NN];
|
||||
let residual = &residual[..NN];
|
||||
let out = &mut out[..NN];
|
||||
let mut tmp = [0f32; NN];
|
||||
for i in 0..n {
|
||||
let t = &mut tmp[i * n..i * n + n];
|
||||
for j in 0..n {
|
||||
let r = residual[i * n + j];
|
||||
if r == 0 {
|
||||
continue;
|
||||
}
|
||||
let r = r as f32;
|
||||
for (o, &b) in t.iter_mut().zip(&brt[j * n..j * n + n]) {
|
||||
*o += r * b;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.iter_mut().for_each(|v| *v = 0.0);
|
||||
for k in 0..n {
|
||||
let o = &mut out[k * n..k * n + n];
|
||||
for i in 0..n {
|
||||
let b = 8.0 * bc[k * n + i];
|
||||
for (v, &t) in o.iter_mut().zip(&tmp[i * n..i * n + n]) {
|
||||
*v += b * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 32x32 (and any other size): a const-generic 32 version measured slower.
|
||||
fn forward_2d_dyn(residual: &[i32], out: &mut [f32], n: usize, tx_type: usize) {
|
||||
let (ck, rk) = kinds(tx_type);
|
||||
let bc = basis(ck, n);
|
||||
let brt = basis_t(rk, n);
|
||||
@@ -454,6 +543,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32};
|
||||
|
||||
/// forward (float) then the normative inverse reproduces the residual: the forward basis
|
||||
/// and scale match the decoder's inverse transforms for every supported size and type.
|
||||
|
||||
+41
-23
@@ -8,7 +8,15 @@ use crate::cdf::Cdfs;
|
||||
use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for};
|
||||
use crate::loopfilter::LfInfo;
|
||||
use crate::symbol::SymbolWriter;
|
||||
use crate::tables::*;
|
||||
use crate::tables::{
|
||||
ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE,
|
||||
D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME,
|
||||
LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV,
|
||||
NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE,
|
||||
PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP,
|
||||
SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH,
|
||||
TX_WIDTH_LOG2, V_PRED,
|
||||
};
|
||||
|
||||
type MvP = [i32; 2];
|
||||
|
||||
@@ -62,6 +70,8 @@ pub(crate) struct TileWriter<'a> {
|
||||
left_dc: [Vec<u8>; 3],
|
||||
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
|
||||
lf_tx: [Vec<u8>; 3],
|
||||
/// Scratch for the levels coded so far in `coeffs`.
|
||||
quant: Vec<i32>,
|
||||
// current block
|
||||
r: usize,
|
||||
c: usize,
|
||||
@@ -114,6 +124,7 @@ impl<'a> TileWriter<'a> {
|
||||
left_level: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
left_dc: std::array::from_fn(|_| vec![0; g.mi_rows + 64]),
|
||||
lf_tx: std::array::from_fn(|_| vec![0; (g.mi_cols + 32) * (g.mi_rows + 32)]),
|
||||
quant: Vec::new(),
|
||||
r: 0,
|
||||
c: 0,
|
||||
bsize: 0,
|
||||
@@ -181,10 +192,10 @@ impl<'a> TileWriter<'a> {
|
||||
self.sw.symbol(partition, cdf);
|
||||
} else if has_cols || has_rows {
|
||||
assert!(split, "blocks crossing the frame edge are split");
|
||||
let pcdf: Vec<u16> = match bsl {
|
||||
2 => self.cdf.partition_w16[ctx].to_vec(),
|
||||
3 => self.cdf.partition_w32[ctx].to_vec(),
|
||||
_ => self.cdf.partition_w64[ctx].to_vec(),
|
||||
let pcdf: &[u16] = match bsl {
|
||||
2 => &self.cdf.partition_w16[ctx],
|
||||
3 => &self.cdf.partition_w32[ctx],
|
||||
_ => &self.cdf.partition_w64[ctx],
|
||||
};
|
||||
let pr = |k: usize| pcdf[k] as i32 - if k == 0 { 0 } else { pcdf[k - 1] as i32 };
|
||||
let psum = if has_cols {
|
||||
@@ -455,23 +466,7 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
} else {
|
||||
if plane == 0 {
|
||||
// transform_type( )
|
||||
let set = tx_set(tx_sz, b.is_inter);
|
||||
let t = b.tx_type_y;
|
||||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
// The decision only picks types of this set (DCT_DCT otherwise).
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
|
||||
} else {
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(t, DCT_DCT);
|
||||
}
|
||||
self.set_tx_types(x4, y4, tx_sz, t);
|
||||
self.write_transform_type(x4, y4, tx_sz, b);
|
||||
}
|
||||
let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b);
|
||||
debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST));
|
||||
@@ -501,7 +496,9 @@ impl<'a> TileWriter<'a> {
|
||||
let height = TX_HEIGHT[tx_sz] as usize;
|
||||
let width = 1usize << bwl;
|
||||
// Quant[] as the decoder sees it while parsing: the levels coded so far (capped)
|
||||
let mut quant = vec![0i32; width * height];
|
||||
let mut quant = std::mem::take(&mut self.quant);
|
||||
quant.clear();
|
||||
quant.resize(width * height, 0);
|
||||
for c in (0..eob).rev() {
|
||||
let pos = scan[c] as usize;
|
||||
let a = levels[pos].unsigned_abs().min(15) as i32;
|
||||
@@ -526,6 +523,7 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
quant[pos] = a;
|
||||
}
|
||||
self.quant = quant;
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let v = levels[pos];
|
||||
@@ -571,6 +569,26 @@ impl<'a> TileWriter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) {
|
||||
// transform_type( )
|
||||
let set = tx_set(tx_sz, b.is_inter);
|
||||
let t = b.tx_type_y;
|
||||
if set > 0 && self.qidx > 0 {
|
||||
let sqr = TX_SIZE_SQR[tx_sz] as usize;
|
||||
if b.is_inter {
|
||||
// The decision only picks types of this set (DCT_DCT otherwise).
|
||||
let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]);
|
||||
} else {
|
||||
let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default();
|
||||
self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(t, DCT_DCT);
|
||||
}
|
||||
self.set_tx_types(x4, y4, tx_sz, t);
|
||||
}
|
||||
|
||||
fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) {
|
||||
for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) {
|
||||
for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Speed / efficiency measurements on ffmpeg-generated test sources (ignored by default):
|
||||
//! `cargo test -p effectcraft-av1enc --test bench -- --ignored --nocapture`.
|
||||
//! `cargo test -p effectcraft-av1enc --test it bench:: -- --ignored --nocapture`.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
File renamed without changes.
@@ -2,7 +2,7 @@
|
||||
//! bit for bit. Skipped when `ffmpeg` (with libdav1d) is not installed. ffmpeg's native `av1`
|
||||
//! decoder only drives hardware decoders, so it is not used.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod bench;
|
||||
mod common;
|
||||
mod ffmpeg_oracle;
|
||||
mod roundtrip;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Round trips through FilmCraft's spec-derived AV1 decoder: decoded frames must equal the
|
||||
//! encoder's own reconstruction bit for bit.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use effectcraft_av1enc::{EncoderConfig, RateControl};
|
||||
@@ -80,6 +80,23 @@ fn bitrate_mode_tracks_target() {
|
||||
assert!(kbps > 400.0 / 2.0 && kbps < 400.0 * 2.0, "{kbps} kbit/s");
|
||||
}
|
||||
|
||||
/// Over several key frame intervals the average rate stays within 5 % of the target, for a
|
||||
/// range of targets (the key frame budget is taken out of the inter frames of its group).
|
||||
#[test]
|
||||
fn bitrate_mode_is_accurate_over_many_groups() {
|
||||
let n = 180;
|
||||
for (kbps, keyint, bd) in [(40, 30, 8), (100, 30, 8), (200, 60, 8), (150, 20, 10)] {
|
||||
let pics: Vec<Pic> = (0..n).map(|t| synth(192, 128, bd, t)).collect();
|
||||
let mut c = cfg(192, 128, bd as u8, 0, keyint);
|
||||
c.rate = RateControl::Bitrate { kbps };
|
||||
let mut enc = effectcraft_av1enc::Encoder::new(c).unwrap();
|
||||
let bits: usize = pics.iter().map(|p| enc.encode(&p.frame()).data.len() * 8).sum();
|
||||
let got = bits as f64 / (n as f64 / 30.0) / 1000.0;
|
||||
eprintln!("{bd} bit, keyint {keyint}: {got:.1} kbit/s for {kbps}");
|
||||
assert!((got / kbps as f64 - 1.0).abs() < 0.05, "{got:.1} kbit/s for a {kbps} kbit/s target ({bd} bit, keyint {keyint})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers() {
|
||||
let enc = effectcraft_av1enc::Encoder::new(cfg(1920, 1080, 10, 100, 60)).unwrap();
|
||||
+53
-5
@@ -39,7 +39,13 @@ impl Rgba {
|
||||
/// `#rrggbb` or `#rrggbbaa`.
|
||||
pub fn from_hex(s: &str) -> Option<Rgba> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
|
||||
let p = |i: usize| {
|
||||
let pair = s.get(i..i + 2)?;
|
||||
if !pair.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(pair, 16).ok()
|
||||
};
|
||||
match s.len() {
|
||||
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
|
||||
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
|
||||
@@ -91,9 +97,11 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let denom = if l > 0.5 { 2.0 - max - min } else { max + min };
|
||||
// Over-range (HDR) or negative inputs can make the denominator zero or negative.
|
||||
let s = if denom > 0.0 { d / denom } else { 1.0 };
|
||||
let h = if max == r {
|
||||
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
||||
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
@@ -130,7 +138,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
let h = if d < 1e-7 {
|
||||
0.0
|
||||
} else if max == r {
|
||||
((g - b) / d).rem_euclid(6.0) / 6.0
|
||||
(((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0)
|
||||
} else if max == g {
|
||||
((b - r) / d + 2.0) / 6.0
|
||||
} else {
|
||||
@@ -141,7 +149,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||
}
|
||||
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
||||
let h6 = h.rem_euclid(1.0) * 6.0;
|
||||
let mut h6 = h.rem_euclid(1.0) * 6.0;
|
||||
// In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
|
||||
if h6 >= 6.0 {
|
||||
h6 = 0.0;
|
||||
}
|
||||
let i = h6.floor();
|
||||
let f = h6 - i;
|
||||
let p = v * (1.0 - s);
|
||||
@@ -252,6 +264,42 @@ impl Label {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsl_round_trip_is_finite_for_out_of_range_colours() {
|
||||
for (r, g, b) in [(1.5, 0.5, 0.5), (2.0, 0.1, 0.3), (-0.5, 0.2, 0.9)] {
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
assert!(h.is_finite() && s.is_finite() && l.is_finite(), "{h} {s} {l}");
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||
assert!(r2.is_finite() && g2.is_finite() && b2.is_finite(), "{r2} {g2} {b2}");
|
||||
}
|
||||
// A 32 bpc colour whose denominator stays positive still round-trips exactly.
|
||||
let (h, s, l) = rgb_to_hsl(1.2, 0.5, 0.5);
|
||||
let (r, g, b) = hsl_to_rgb(h, s, l);
|
||||
assert!((r - 1.2).abs() < 1e-5 && (g - 0.5).abs() < 1e-5 && (b - 0.5).abs() < 1e-5, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_to_rgb_tiny_negative_hue_is_red() {
|
||||
let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
|
||||
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_stays_below_one_for_tiny_blue() {
|
||||
let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9);
|
||||
assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1");
|
||||
let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9);
|
||||
assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_signed_hex_pairs() {
|
||||
assert!(Rgba::from_hex("#+1+2+3").is_none());
|
||||
assert!(Rgba::from_hex("#1+2+3+").is_none());
|
||||
assert!(Rgba::from_hex("#ff00+0ff").is_none());
|
||||
assert!(Rgba::from_hex("#a1B2c3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_roundtrip() {
|
||||
for i in 0..=100 {
|
||||
|
||||
@@ -177,7 +177,7 @@ impl ColorSpace {
|
||||
|
||||
/// Parse an id or label (case-insensitive, loose).
|
||||
pub fn parse(s: &str) -> Option<ColorSpace> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
|
||||
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
|
||||
|
||||
@@ -21,4 +21,5 @@ rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Bounded parsed-JSON caches. Keep the content key with the owned source text so repeated
|
||||
//! renders do not rehash long analyses. Compare contents on lookup: evaluated parameter
|
||||
//! strings can be cloned, edited in place, or allocated again at an old address.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
struct Entry<T> {
|
||||
json: Box<str>,
|
||||
key: u64,
|
||||
value: Arc<T>,
|
||||
}
|
||||
|
||||
pub(crate) struct AnalysisCache<T> {
|
||||
entries: OnceLock<Mutex<Vec<Entry<T>>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
impl<T> AnalysisCache<T> {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self { entries: OnceLock::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, json: &str, parse: impl FnOnce(&str) -> Option<T>) -> Option<(u64, Arc<T>)> {
|
||||
let entries = self.entries.get_or_init(Default::default);
|
||||
{
|
||||
let m = entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
|
||||
return Some((e.key, e.value.clone()));
|
||||
}
|
||||
}
|
||||
let value = Arc::new(parse(json)?);
|
||||
let key = fnv(json.as_bytes());
|
||||
let mut m = entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another render may have filled the entry while this one parsed it.
|
||||
if let Some(e) = m.iter().rev().find(|e| e.json.as_ref() == json) {
|
||||
return Some((e.key, e.value.clone()));
|
||||
}
|
||||
// Preserve the existing nine-entry bound and clear-on-overflow policy.
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.push(Entry { json: json.into(), key, value: value.clone() });
|
||||
Some((key, value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn reuses_content_and_invalidates_same_allocation_edits() {
|
||||
let cache = AnalysisCache::new();
|
||||
let calls = Cell::new(0);
|
||||
let parse = |s: &str| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(s.to_owned())
|
||||
};
|
||||
let mut json = String::from("analysis-1");
|
||||
let (key, a) = cache.get(&json, parse).unwrap();
|
||||
let copy = json.clone();
|
||||
assert_ne!(json.as_ptr(), copy.as_ptr());
|
||||
let (same_key, same) = cache.get(©, parse).unwrap();
|
||||
assert_eq!(key, fnv(json.as_bytes()));
|
||||
assert_eq!(key, same_key);
|
||||
assert!(Arc::ptr_eq(&a, &same));
|
||||
assert_eq!(calls.get(), 1);
|
||||
let ptr = json.as_ptr();
|
||||
json.replace_range(9..10, "2");
|
||||
assert_eq!(ptr, json.as_ptr());
|
||||
let (changed_key, changed) = cache.get(&json, parse).unwrap();
|
||||
assert_ne!(key, changed_key);
|
||||
assert_eq!(&*changed, "analysis-2");
|
||||
assert_eq!(&*a, "analysis-1");
|
||||
assert_eq!(calls.get(), 2);
|
||||
assert!(cache.get("invalid", |_| None).is_none());
|
||||
assert!(Arc::ptr_eq(&cache.get(&json, parse).unwrap().1, &changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_text_retention_is_bounded() {
|
||||
let cache = AnalysisCache::new();
|
||||
for i in 0..30 {
|
||||
cache.get(&i.to_string(), |s| Some(s.to_owned())).unwrap();
|
||||
assert!(cache.entries.get().unwrap().lock().unwrap().len() <= 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,12 @@ pub fn reverses(id: &str) -> bool {
|
||||
id == "ec.audio.backwards"
|
||||
}
|
||||
|
||||
/// Does each output sample depend only on the input sample at the same time (no state), so the
|
||||
/// mixer may run the effect in short pieces with parameters animated between them?
|
||||
pub fn per_sample(id: &str) -> bool {
|
||||
matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards")
|
||||
}
|
||||
|
||||
/// Seconds of input history the effect needs before a block to reach the state it would have
|
||||
/// had when processing continuously.
|
||||
pub fn preroll(id: &str, params: &Params) -> f64 {
|
||||
|
||||
@@ -509,11 +509,24 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// CC Radial Fast Blur
|
||||
|
||||
/// Transparent pixels CC Radial Fast Blur adds on every side of a `w` × `h` buffer with Center
|
||||
/// at `c` (buffer pixels) and smear `s`: content `r` from Center streaks out to `r / (1 − s)`,
|
||||
/// so the farthest corner reaches `d · s / (1 − s)` past itself (at most 4096, as the other
|
||||
/// growing effects). Shared with the GPU path so both grow the same way.
|
||||
pub fn radial_fast_pad(c: (f64, f64), w: u32, h: u32, s: f64) -> u32 {
|
||||
let (w, h) = (f64::from(w), f64::from(h));
|
||||
let far = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - c.0).hypot(y - c.1)).fold(0.0, f64::max);
|
||||
((far * s / (1.0 - s)).ceil() + 1.0).min(4096.0) as u32
|
||||
}
|
||||
|
||||
fn radial_fast_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
|
||||
if s <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
if !ctx.adjustment {
|
||||
b.pad(radial_fast_pad(b.to_px(ctx.params.v2("center")), b.img.width, b.img.height, s));
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("center"));
|
||||
let mode = ctx.params.e("zoom");
|
||||
let src = b.img.clone();
|
||||
@@ -906,6 +919,23 @@ mod tests {
|
||||
assert_eq!(circle.len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_fast_blur_streaks_past_the_layer_edge() {
|
||||
// Center left of an opaque square: the streaks run right, beyond the layer rectangle (#2095).
|
||||
let img = Image::filled(20, 20, [1.0, 1.0, 1.0, 1.0]);
|
||||
let out = run("ec.blur.ccradialfast", &img, &[("center", Value::Vec2([-20.0, 10.0])), ("amount", num(80.0))]);
|
||||
assert!(out.img.width > 20 && out.img.height > 20, "{}x{}", out.img.width, out.img.height);
|
||||
let (o, d) = (out.offset[0] as i64, out.offset[1] as i64);
|
||||
assert!(out.img.get(30 + o, 10 + d)[3] > 0.1, "{:?}", out.img.get(30 + o, 10 + d));
|
||||
// Its streak samples only the square: still opaque, so the content keeps its place.
|
||||
assert!((out.img.get(19 + o, 10 + d)[3] - 1.0).abs() < 1e-5, "{:?}", out.img.get(19 + o, 10 + d));
|
||||
// An adjustment layer is comp-sized already; its buffer is not grown.
|
||||
let s = find("ec.blur.ccradialfast").unwrap();
|
||||
let params = Params { values: [("center".into(), Value::Vec2([-20.0, 10.0])), ("amount".into(), num(80.0))].into_iter().collect() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [20.0, 20.0], seed: 1, adjustment: true, env: Default::default() };
|
||||
assert_eq!((s.render)(&ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img.width, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_flicker_blurs_vertically_only() {
|
||||
let mut img = Image::filled(8, 8, [0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
//! on it draws the solved points (as the viewer shows them) into the frame. The viewer overlay, target and the Create
|
||||
//! commands live in the engine and the UI.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::analysis_cache::AnalysisCache;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
@@ -122,50 +123,24 @@ pub fn solve_key(st: &SolveSettings) -> String {
|
||||
format!("{:?}|{hfov}|{:?}|{:?}{lens}", st.shot, st.method, st.deleted)
|
||||
}
|
||||
|
||||
fn fnv(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
type Cache<T> = Mutex<HashMap<u64, Arc<T>>>;
|
||||
|
||||
fn cached<T>(c: &'static OnceLock<Cache<T>>, json: &str, parse: impl Fn(&str) -> Option<T>) -> Option<Arc<T>> {
|
||||
let key = fnv(json.as_bytes());
|
||||
let c = c.get_or_init(Default::default);
|
||||
if let Some(a) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Some(a.clone());
|
||||
}
|
||||
let a = Arc::new(parse(json)?);
|
||||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert(key, a.clone());
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// The stored feature tracks of an instance (`None` when not analysed).
|
||||
pub fn tracks(params: &Params) -> Option<Arc<CameraTracks>> {
|
||||
static C: OnceLock<Cache<CameraTracks>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraTracks> = AnalysisCache::new();
|
||||
let j = s(params, TRACKS);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraTracks::from_json).filter(|t| !t.is_empty())
|
||||
C.get(j, CameraTracks::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// The stored camera solve of an instance (`None` when not solved).
|
||||
pub fn solve(params: &Params) -> Option<Arc<CameraSolve>> {
|
||||
static C: OnceLock<Cache<CameraSolve>> = OnceLock::new();
|
||||
static C: AnalysisCache<CameraSolve> = AnalysisCache::new();
|
||||
let j = s(params, SOLVE);
|
||||
if j.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cached(&C, j, CameraSolve::from_json).filter(|t| !t.is_empty())
|
||||
C.get(j, CameraSolve::from_json).map(|(_, a)| a).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Screen size (layer pixels) of a target drawn for a point at depth `z`, relative to the
|
||||
@@ -338,6 +313,27 @@ mod tests {
|
||||
assert!(!st.lens_distortion && !solve_key(&st).contains("lens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_and_solve_follow_analysis_replacements() {
|
||||
let mut t = CameraTracks { frames: 3, start: 1.0, ..Default::default() };
|
||||
let mut sol = CameraSolve { frames: vec![SolvedFrame::default()], start: 1.0, ..Default::default() };
|
||||
let mut ps = params_with(&sol, false);
|
||||
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
|
||||
assert_eq!(*tracks(&ps).unwrap(), t);
|
||||
assert_eq!(*solve(&ps).unwrap(), sol);
|
||||
t.start = 2.0;
|
||||
sol.start = 2.0;
|
||||
ps.values.insert(TRACKS.into(), Value::Str(t.to_json()));
|
||||
ps.values.insert(SOLVE.into(), Value::Str(sol.to_json()));
|
||||
assert_eq!(*tracks(&ps).unwrap(), t);
|
||||
assert_eq!(*solve(&ps).unwrap(), sol);
|
||||
assert_eq!(*tracks(&ps.clone()).unwrap(), t);
|
||||
ps.values.insert(TRACKS.into(), Value::Str("invalid".into()));
|
||||
ps.values.insert(SOLVE.into(), Value::Str(String::new()));
|
||||
assert!(tracks(&ps).is_none());
|
||||
assert!(solve(&ps).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undistort_footage_remaps_through_the_solved_lens() {
|
||||
use effectcraft_track::camtrack::Distortion;
|
||||
|
||||
@@ -92,7 +92,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
||||
];
|
||||
|
||||
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
|
||||
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// pinning).
|
||||
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
@@ -182,6 +182,20 @@ mod tests {
|
||||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
/// The README quotes the number of effects (#2565): every count it gives is the registry's.
|
||||
#[test]
|
||||
fn readme_effect_counts_are_current() {
|
||||
let readme = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default();
|
||||
let words: Vec<&str> = readme.split_whitespace().collect();
|
||||
let counts: Vec<&str> = words
|
||||
.windows(3)
|
||||
.filter(|w| w[0].parse::<u32>().is_ok() && (w[1].starts_with("effects") || (w[1] == "built-in" && w[2].starts_with("effects"))))
|
||||
.map(|w| w[0])
|
||||
.collect();
|
||||
let n = super::registry().len().to_string();
|
||||
assert!(!counts.is_empty() && counts.iter().all(|c| *c == n), "README.md says {counts:?} effects; the registry has {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_only_controls_keep_effects_off_the_gpu() {
|
||||
use effectcraft_keyframe::Value;
|
||||
|
||||
@@ -49,12 +49,15 @@ fn set_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Shift Channels' Take … From menu. After Effects' ten options come first, in its order, so a
|
||||
/// project or script that sets 10 ("Full Off" there) turns the channel off here too; "Half On" is
|
||||
/// an EffectCraft extra and sits last.
|
||||
const SHIFT_ORDER: [Src; 11] =
|
||||
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Half, Src::Off];
|
||||
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"];
|
||||
[Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half];
|
||||
const SHIFT_NAMES: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Full Off", "Half On"];
|
||||
|
||||
fn shift_channels(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(10)]);
|
||||
let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| SHIFT_ORDER[(ctx.params.e(id) as usize).min(SHIFT_ORDER.len() - 1)]);
|
||||
route(&mut b, s);
|
||||
b
|
||||
}
|
||||
@@ -548,6 +551,24 @@ mod tests {
|
||||
assert!(close(out.get(7, 0), [0.25, 0.25, 0.45, 0.5]));
|
||||
}
|
||||
|
||||
/// #1797: the Take … From menu keeps After Effects' order, where the tenth option (`Enum(9)`
|
||||
/// here, 10 to a script) is Full Off, not Half On.
|
||||
#[test]
|
||||
fn shift_channels_menu_follows_after_effects_order() {
|
||||
assert_eq!(SHIFT_NAMES[8..], ["Full On", "Full Off", "Half On"]);
|
||||
// The sample's right-hand pixels are half transparent, so a straight 0.0 and 0.5 are
|
||||
// stored as 0.0 and 0.25.
|
||||
let off = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(9))], sample());
|
||||
assert!(close(off.get(0, 0), [0.2, 0.0, 0.9, 1.0]), "{:?}", off.get(0, 0));
|
||||
assert!(close(off.get(7, 0), [0.1, 0.0, 0.45, 0.5]), "{:?}", off.get(7, 0));
|
||||
let half = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(10))], sample());
|
||||
assert!(close(half.get(0, 0), [0.2, 0.5, 0.9, 1.0]), "{:?}", half.get(0, 0));
|
||||
assert!(close(half.get(7, 0), [0.1, 0.25, 0.45, 0.5]), "{:?}", half.get(7, 0));
|
||||
// A value past the last option stays on the last one instead of panicking.
|
||||
let past = run("ec.channel.shiftchannels", &[("takeGreenFrom", Value::Enum(99))], sample());
|
||||
assert_eq!(past.get(0, 0), half.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_combiner_hls_round_trip() {
|
||||
let img = sample();
|
||||
|
||||
+100
-359
@@ -7,6 +7,11 @@
|
||||
//! pass for a precomp of a 3D comp. Without auxiliary channels they pass the layer through
|
||||
//! (EXtractoR still maps the layer's own R, G, B and A).
|
||||
//!
|
||||
//! 3D Layer Instance (Create 3D Layer Instance's own effect, Layer ▸ Create) is not an aux
|
||||
//! reader: it replaces the layer's pixels with a referenced layer's render — for a 3D model
|
||||
//! layer, its render on its own through the comp's camera — so 2D effects can be applied to a
|
||||
//! 3D layer's render.
|
||||
//!
|
||||
//! Aux pixels are looked up nearest-neighbour (IDs must not be interpolated) at each buffer
|
||||
//! pixel's layer position, so render scale and layer padding are respected.
|
||||
|
||||
@@ -37,16 +42,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
|
||||
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
|
||||
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec<Option<usize>> {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
(0..w * h)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]);
|
||||
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
|
||||
let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]);
|
||||
a.index_at(x, y)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel
|
||||
/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls
|
||||
/// it too, so the two can't pick different aux pixels.
|
||||
#[inline]
|
||||
pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] {
|
||||
let inv = 1.0 / scale.max(1e-9);
|
||||
[(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv]
|
||||
}
|
||||
|
||||
/// A channel sampled into the buffer grid (`fill` outside).
|
||||
fn plane_of(b: &Buf, idx: &[Option<usize>], ch: &[f32], fill: f32) -> Plane {
|
||||
Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() }
|
||||
@@ -326,7 +339,7 @@ fn wild(pat: &str, name: &str) -> bool {
|
||||
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
|
||||
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
|
||||
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
|
||||
fn selection_names(sel: &str) -> Vec<String> {
|
||||
pub(crate) fn selection_names(sel: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
@@ -351,7 +364,7 @@ fn selection_names(sel: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
|
||||
/// Parse a selection: comma-separated names (optionally quoted, see `selection_names`) or
|
||||
/// `<hex>` hashes / wildcards.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
@@ -395,13 +408,42 @@ fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 {
|
||||
.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// [`id_color`] of the last ID seen on each rank plane, per worker. Neighbouring pixels mostly
|
||||
/// carry the same IDs, so this skips the hash and the HSL conversion for most of them. Keyed by the
|
||||
/// ID's bits, so it returns exactly what [`id_color`] would.
|
||||
///
|
||||
/// Keep the per-pixel loop below as it is: with NaN coverage, which NaN payload ends up in a
|
||||
/// channel depends on the order the compiler adds the ranks in, and a row-wise loop let it reorder
|
||||
/// them (`crypto_aux_image_is_bit_exact_on_edge_values` catches that).
|
||||
#[derive(Default)]
|
||||
struct IdColorMemo(Vec<(u32, [f32; 3])>);
|
||||
|
||||
impl IdColorMemo {
|
||||
#[inline]
|
||||
fn get(&mut self, rank: usize, id: f32) -> [f32; 3] {
|
||||
let bits = id.to_bits();
|
||||
match self.0.get_mut(rank) {
|
||||
Some(e) if e.0 == bits => e.1,
|
||||
Some(e) => {
|
||||
*e = (bits, id_color(id));
|
||||
e.1
|
||||
}
|
||||
None => {
|
||||
let c = id_color(id);
|
||||
self.0.resize(rank + 1, (bits, c));
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The coverage-weighted ID colours at aux pixel `i` (the spec's preview).
|
||||
#[inline]
|
||||
fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
|
||||
fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; 3] {
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in planes {
|
||||
for (rank, (id, cov)) in planes.iter().enumerate() {
|
||||
let (Some(&id), Some(&cov)) = (id.get(i), cov.get(i)) else { continue };
|
||||
let k = id_color(id);
|
||||
let k = memo.get(rank, id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
@@ -413,10 +455,37 @@ fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] {
|
||||
/// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU
|
||||
/// effect uploads this image and samples it per layer pixel.
|
||||
pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image {
|
||||
crypto_aux_image_with(a, layer, sel, CryptoAux::ColorsAndMatte)
|
||||
}
|
||||
|
||||
/// What [`crypto_aux_image_with`] computes per aux pixel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CryptoAux {
|
||||
/// The coverage-weighted ID colours (RGB) and the selection's coverage (A).
|
||||
ColorsAndMatte,
|
||||
/// Only the selection's coverage (A); the RGB stays 0.
|
||||
MatteOnly,
|
||||
}
|
||||
|
||||
impl CryptoAux {
|
||||
/// What the Cryptomatte effect reads for `display` and Matte Only: the ID colours are only
|
||||
/// read by the Colors display (0) without Matte Only; Matte, Preview and Matte Only need the
|
||||
/// matte alone.
|
||||
pub fn for_display(display: u32, matte_only: bool) -> Self {
|
||||
if display == 0 && !matte_only { Self::ColorsAndMatte } else { Self::MatteOnly }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crypto_aux_image`]; with [`CryptoAux::MatteOnly`] the RGB stays 0 and only the matte (A) is
|
||||
/// computed, for the displays that never read the ID colours.
|
||||
pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], what: CryptoAux) -> Image {
|
||||
let planes = a.crypto_rank_planes(layer);
|
||||
let mut img = Image::new(a.width, a.height);
|
||||
img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let c = crypto_colors(&planes, i);
|
||||
img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| {
|
||||
let c = match what {
|
||||
CryptoAux::ColorsAndMatte => crypto_colors(&planes, i, memo),
|
||||
CryptoAux::MatteOnly => [0.0; 3],
|
||||
};
|
||||
*px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)];
|
||||
});
|
||||
img
|
||||
@@ -436,9 +505,8 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let display = ctx.params.e("display");
|
||||
let matte_only = ctx.params.b("matteOnly");
|
||||
let planes = a.crypto_rank_planes(&layer);
|
||||
let src = b.img.clone();
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k));
|
||||
b.img.data.par_iter_mut().zip(idx.par_iter()).for_each_init(IdColorMemo::default, |memo, (px, &k)| {
|
||||
let m = k.map_or(0.0, |k| crypto_matte(&planes, &sel, k));
|
||||
if matte_only {
|
||||
*px = [m, m, m, m];
|
||||
return;
|
||||
@@ -446,15 +514,15 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
*px = match display {
|
||||
// Colors: coverage-weighted ID colours (the spec's preview), selection brighter.
|
||||
0 => {
|
||||
let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k));
|
||||
let c = k.map_or([0.0; 3], |k| crypto_colors(&planes, k, memo));
|
||||
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
}
|
||||
// Matte: the layer keyed by the selection.
|
||||
1 => src.data[i].map(|v| v * m),
|
||||
// Matte: the layer keyed by the selection (each pixel only reads itself, so no copy).
|
||||
1 => px.map(|v| v * m),
|
||||
// Preview: the layer with the selection tinted.
|
||||
_ => {
|
||||
let s = src.data[i];
|
||||
let s = *px;
|
||||
[s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]]
|
||||
}
|
||||
};
|
||||
@@ -519,6 +587,19 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// A 2D copy of a referenced layer (Create 3D Layer Instance's solid): the layer's pixels
|
||||
/// replaced with the referenced layer's render, alpha included — the copy is transparent
|
||||
/// where the referenced layer isn't. A 3D model or primitive layer is rendered on its own
|
||||
/// through the comp's camera, as After Effects renders 3D layers; another layer is read with
|
||||
/// its masks and effects. With no layer chosen the effect passes the layer through, as a layer
|
||||
/// parameter with nothing chosen does.
|
||||
fn instance_3d(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if let Some(o) = ctx.layer_param("layer", true) {
|
||||
b.img = crate::util::fit_layer(ctx, &b, &o, false);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let text = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Text);
|
||||
vec![
|
||||
@@ -619,349 +700,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
],
|
||||
identifier,
|
||||
),
|
||||
spec("ec.3d.instance3d", "3D Layer Instance", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)], instance_3d),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
use effectcraft_raster::channels3d::crypto_float;
|
||||
|
||||
/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
|
||||
/// top-right corner is background. Cryptomatte: "left" / "right".
|
||||
struct AuxHost(Arc<AuxChannels>);
|
||||
impl EffectHost for AuxHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn aux(&self) -> Option<Arc<AuxChannels>> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn scene() -> AuxHost {
|
||||
let (w, h) = (20usize, 10usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
|
||||
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if x >= 15 && y < 3 {
|
||||
z[i] = BACKGROUND_DEPTH;
|
||||
} else if x < 10 {
|
||||
z[i] = 100.0;
|
||||
id[i] = 1.0;
|
||||
r[i] = crypto_float(hl);
|
||||
g[i] = 1.0;
|
||||
} else {
|
||||
z[i] = 500.0;
|
||||
id[i] = 2.0;
|
||||
r[i] = crypto_float(hr);
|
||||
g[i] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
|
||||
a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
|
||||
AuxHost(Arc::new(a))
|
||||
}
|
||||
|
||||
fn img() -> Image {
|
||||
Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
|
||||
}
|
||||
|
||||
fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
|
||||
run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_without_aux_channels() {
|
||||
for s in specs() {
|
||||
let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, img().data, "{}", s.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_matte_thresholds() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
|
||||
assert_eq!(inv.get(2, 5)[3], 1.0);
|
||||
assert_eq!(inv.get(12, 5)[3], 0.0);
|
||||
// Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
|
||||
let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
|
||||
assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_maps_depth_and_ids() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(o.get(17, 1)[0], 1.0);
|
||||
let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
|
||||
assert_eq!(ids.get(2, 5)[0], 0.5);
|
||||
// Invert Depth Map flips the ramp; without Clamp Output values run past 1.
|
||||
let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h);
|
||||
assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6);
|
||||
let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h);
|
||||
assert!(raw.get(12, 5)[0] > 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matte_and_identifier() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
|
||||
assert_eq!(l.get(2, 5)[0], 1.0);
|
||||
assert_eq!(l.get(12, 5)[0], 0.0);
|
||||
let c = run("ec.3d.identifier", &[], &h);
|
||||
assert_ne!(c.get(2, 5), c.get(12, 5));
|
||||
}
|
||||
|
||||
/// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel
|
||||
/// reduction over `crypto_ranks` it replaces.
|
||||
#[test]
|
||||
fn crypto_aux_image_matches_the_per_pixel_reduction() {
|
||||
let (w, h) = (7u32, 5u32);
|
||||
let n = (w * h) as usize;
|
||||
let mut a = AuxChannels::new(w, h, 1.0);
|
||||
let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float);
|
||||
for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() {
|
||||
let v: Vec<f32> = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect();
|
||||
a.channels.push((format!("CryptoObject{ch}"), v));
|
||||
}
|
||||
let sel = vec![crypto_hash("sphere"), crypto_hash("ring")];
|
||||
let img = crypto_aux_image(&a, "CryptoObject", &sel);
|
||||
for i in 0..n {
|
||||
let ranks = a.crypto_ranks("CryptoObject", i);
|
||||
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in &ranks {
|
||||
let k = id_color(*id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
}
|
||||
let want = [c[0], c[1], c[2], m];
|
||||
assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookup before #473, kept as the reference: the rank channels formatted and searched
|
||||
/// by name for every pixel.
|
||||
fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> {
|
||||
let mut out = vec![];
|
||||
for k in 0.. {
|
||||
let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}"));
|
||||
let (Some(r), Some(g)) = (get("R"), get("G")) else { break };
|
||||
out.push((r[i], g[i]));
|
||||
if let (Some(b), Some(al)) = (get("B"), get("A")) {
|
||||
out.push((b[i], al[i]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The reduction before #473 over those ranks: (ID colours, selection coverage).
|
||||
fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) {
|
||||
let ranks = ranks_by_name(a, layer, i);
|
||||
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in &ranks {
|
||||
let k = id_color(*id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
}
|
||||
(c, m)
|
||||
}
|
||||
|
||||
/// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN
|
||||
/// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values
|
||||
/// that would change every pixel) must be ignored as before.
|
||||
fn edge_aux(with_rank_after_gap: bool) -> AuxChannels {
|
||||
let (w, h) = (6u32, 2u32);
|
||||
let nan_payload = f32::from_bits(0x7fc0_1234);
|
||||
let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube")));
|
||||
let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5];
|
||||
let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0];
|
||||
let rot = |v: &[f32; 12], k: usize| -> Vec<f32> { (0..12).map(|i| v[(i + k) % 12]).collect() };
|
||||
let mut a = AuxChannels::new(w, h, 1.0);
|
||||
let mut push = |n: &str, v: Vec<f32>| a.channels.push((format!("CryptoObject{n}"), v));
|
||||
push("00.R", rot(&ids, 0));
|
||||
push("00.G", rot(&cov, 0));
|
||||
push("00.B", rot(&ids, 3));
|
||||
push("00.A", rot(&cov, 5));
|
||||
push("01.R", rot(&ids, 7));
|
||||
push("01.G", rot(&cov, 2));
|
||||
if with_rank_after_gap {
|
||||
push("03.R", vec![hs; 12]);
|
||||
push("03.G", vec![1.0; 12]);
|
||||
push("03.B", vec![hc; 12]);
|
||||
push("03.A", vec![1.0; 12]);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn edge_selection() -> Vec<u32> {
|
||||
vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
|
||||
}
|
||||
|
||||
/// Edge values give bit-identical results to the lookup by name, the missing B/A pair is
|
||||
/// skipped and a rank after a gap is ignored.
|
||||
#[test]
|
||||
fn crypto_aux_image_is_bit_exact_on_edge_values() {
|
||||
let sel = edge_selection();
|
||||
let a = edge_aux(true);
|
||||
assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap");
|
||||
let img = crypto_aux_image(&a, "CryptoObject", &sel);
|
||||
let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel);
|
||||
for i in 0..12 {
|
||||
let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i);
|
||||
let want = [c[0], c[1], c[2], m];
|
||||
assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
|
||||
assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used");
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
|
||||
/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
|
||||
/// get an empty matte and no ID colour.
|
||||
#[test]
|
||||
fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() {
|
||||
let a = edge_aux(true);
|
||||
let host = AuxHost(Arc::new(a.clone()));
|
||||
let names = "sphere, <7fc00000>, <7fc01234>, <80000000>";
|
||||
assert_eq!(selection_hashes(names, None), edge_selection());
|
||||
let src = img().data[0];
|
||||
for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] {
|
||||
let out = run(
|
||||
"ec.3d.cryptomatte",
|
||||
&[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))],
|
||||
&host,
|
||||
);
|
||||
let expect = |c: [f32; 3], m: f32| -> [f32; 4] {
|
||||
if matte_only {
|
||||
return [m; 4];
|
||||
}
|
||||
match display {
|
||||
0 => {
|
||||
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
}
|
||||
1 => src.map(|v| v * m),
|
||||
_ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]],
|
||||
}
|
||||
};
|
||||
for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] {
|
||||
let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize);
|
||||
assert_eq!(
|
||||
out.get(x as i64, y as i64).map(f32::to_bits),
|
||||
expect(c, m).map(f32::to_bits),
|
||||
"display {display} matteOnly {matte_only} at ({x}, {y})"
|
||||
);
|
||||
}
|
||||
for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] {
|
||||
assert_eq!(
|
||||
out.get(x as i64, y as i64).map(f32::to_bits),
|
||||
expect([0.0; 3], 0.0).map(f32::to_bits),
|
||||
"display {display} matteOnly {matte_only} outside the aux at ({x}, {y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cryptomatte_selects_by_name_and_wildcard() {
|
||||
let h = scene();
|
||||
let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
|
||||
assert_eq!(m.get(2, 5)[3], 0.0);
|
||||
assert_eq!(m.get(12, 5)[3], 1.0);
|
||||
let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
|
||||
assert_eq!(w.get(2, 5)[3], 1.0);
|
||||
assert_eq!(w.get(12, 5)[3], 0.0);
|
||||
assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
|
||||
let hex = format!("<{:08x}>", crypto_hash("left"));
|
||||
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
|
||||
}
|
||||
|
||||
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
|
||||
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
|
||||
#[test]
|
||||
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
|
||||
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
|
||||
let m = Some(manifest.as_slice());
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
|
||||
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
|
||||
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
|
||||
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
|
||||
// Without a manifest the whole name is hashed.
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
|
||||
// Plain lists, apostrophes inside names and an unclosed quote still parse.
|
||||
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
|
||||
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
|
||||
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
|
||||
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fog_and_depth_of_field_follow_depth() {
|
||||
let h = scene();
|
||||
let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
|
||||
// Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
|
||||
assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
|
||||
assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
|
||||
assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
|
||||
assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
|
||||
assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
|
||||
assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
|
||||
// A sharp edge at the far object blurs when focused near.
|
||||
let mut pic = Image::new(20, 10);
|
||||
for y in 0..10 {
|
||||
for x in 0..20 {
|
||||
let v = if x % 2 == 0 { 1.0 } else { 0.0 };
|
||||
pic.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let env = EffectEnv { host: Some(&h), ..Default::default() };
|
||||
let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
|
||||
assert_eq!(d.get(4, 5), pic.get(4, 5));
|
||||
assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_maps_named_channels() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
|
||||
assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
|
||||
// Without aux: the layer's own channels can be swapped.
|
||||
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
let mut a = AuxChannels::new(40, 20, 2.0);
|
||||
a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
|
||||
let hard = run("ec.3d.channelextract", &vals, &host);
|
||||
let v = hard.get(5, 5)[0];
|
||||
assert!(v == 0.0 || v == 1.0, "{v}");
|
||||
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
|
||||
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,401 @@
|
||||
use super::*;
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels, run_fx};
|
||||
use effectcraft_raster::channels3d::crypto_float;
|
||||
|
||||
/// A 20×10 layer: left half object 1 at depth 100, right half object 2 at depth 500; the
|
||||
/// top-right corner is background. Cryptomatte: "left" / "right".
|
||||
struct AuxHost(Arc<AuxChannels>);
|
||||
impl EffectHost for AuxHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn aux(&self) -> Option<Arc<AuxChannels>> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn scene() -> AuxHost {
|
||||
let (w, h) = (20usize, 10usize);
|
||||
let mut a = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
let (mut z, mut id, mut r, mut g) = (vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h], vec![0.0; w * h]);
|
||||
let (hl, hr) = (crypto_hash("left"), crypto_hash("right"));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if x >= 15 && y < 3 {
|
||||
z[i] = BACKGROUND_DEPTH;
|
||||
} else if x < 10 {
|
||||
z[i] = 100.0;
|
||||
id[i] = 1.0;
|
||||
r[i] = crypto_float(hl);
|
||||
g[i] = 1.0;
|
||||
} else {
|
||||
z[i] = 500.0;
|
||||
id[i] = 2.0;
|
||||
r[i] = crypto_float(hr);
|
||||
g[i] = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
a.channels = vec![("Z".into(), z), ("ObjectID".into(), id), ("CryptoObject00.R".into(), r), ("CryptoObject00.G".into(), g)];
|
||||
a.manifests = vec![("CryptoObject".into(), vec![("left".into(), hl), ("right".into(), hr)])];
|
||||
AuxHost(Arc::new(a))
|
||||
}
|
||||
|
||||
fn img() -> Image {
|
||||
Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0])
|
||||
}
|
||||
|
||||
fn run(id: &str, vals: &[(&str, Value)], host: &AuxHost) -> Image {
|
||||
run_fx(id, vals, img(), 0.0, EffectEnv { host: Some(host), ..Default::default() }).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passes_through_without_aux_channels() {
|
||||
for s in specs() {
|
||||
let out = run_fx(s.id, &[], img(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, img().data, "{}", s.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_matte_thresholds() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.depthmatte", &[("depth", num(300.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let inv = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("invert", Value::Bool(true))], &h);
|
||||
assert_eq!(inv.get(2, 5)[3], 1.0);
|
||||
assert_eq!(inv.get(12, 5)[3], 0.0);
|
||||
// Feather: a ramp of 800 units centred on 300 → depth 100 at 0.25, 500 at 0.75.
|
||||
let f = run("ec.3d.depthmatte", &[("depth", num(300.0)), ("feather", num(800.0))], &h);
|
||||
assert!((f.get(2, 5)[3] - 0.25).abs() < 1e-5 && (f.get(12, 5)[3] - 0.75).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_maps_depth_and_ids() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6 && (o.get(12, 5)[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(o.get(17, 1)[0], 1.0);
|
||||
let ids = run("ec.3d.channelextract", &[("channel", Value::Enum(1)), ("whitePoint", num(2.0))], &h);
|
||||
assert_eq!(ids.get(2, 5)[0], 0.5);
|
||||
// Invert Depth Map flips the ramp; without Clamp Output values run past 1.
|
||||
let inv = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(1000.0)), ("invertDepthMap", Value::Bool(true))], &h);
|
||||
assert!((inv.get(2, 5)[0] - 0.9).abs() < 1e-6);
|
||||
let raw = run("ec.3d.channelextract", &[("blackPoint", num(0.0)), ("whitePoint", num(100.0)), ("clampOutput", Value::Bool(false))], &h);
|
||||
assert!(raw.get(12, 5)[0] > 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matte_and_identifier() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.idmatte", &[("idSelection", num(2.0))], &h);
|
||||
assert_eq!(o.get(2, 5)[3], 0.0);
|
||||
assert_eq!(o.get(12, 5)[3], 1.0);
|
||||
let l = run("ec.3d.identifier", &[("display", Value::Enum(1)), ("id", num(1.0))], &h);
|
||||
assert_eq!(l.get(2, 5)[0], 1.0);
|
||||
assert_eq!(l.get(12, 5)[0], 0.0);
|
||||
let c = run("ec.3d.identifier", &[], &h);
|
||||
assert_ne!(c.get(2, 5), c.get(12, 5));
|
||||
}
|
||||
|
||||
/// The parallel aux image (rank planes resolved once) is bit-identical to the per-pixel
|
||||
/// reduction over `crypto_ranks` it replaces.
|
||||
#[test]
|
||||
fn crypto_aux_image_matches_the_per_pixel_reduction() {
|
||||
let (w, h) = (7u32, 5u32);
|
||||
let n = (w * h) as usize;
|
||||
let mut a = AuxChannels::new(w, h, 1.0);
|
||||
let ids = [crypto_hash("cube"), crypto_hash("sphere"), crypto_hash("ring")].map(effectcraft_raster::channels3d::crypto_float);
|
||||
for (k, ch) in ["00.R", "00.G", "00.B", "00.A", "01.R", "01.G", "01.B", "01.A"].iter().enumerate() {
|
||||
let v: Vec<f32> = (0..n).map(|i| if k % 2 == 0 { ids[(i + k) % 3] } else { ((i * 7 + k * 3) % 11) as f32 / 10.0 }).collect();
|
||||
a.channels.push((format!("CryptoObject{ch}"), v));
|
||||
}
|
||||
let sel = vec![crypto_hash("sphere"), crypto_hash("ring")];
|
||||
let img = crypto_aux_image(&a, "CryptoObject", &sel);
|
||||
for i in 0..n {
|
||||
let ranks = a.crypto_ranks("CryptoObject", i);
|
||||
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in &ranks {
|
||||
let k = id_color(*id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
}
|
||||
let want = [c[0], c[1], c[2], m];
|
||||
assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookup before #473, kept as the reference: the rank channels formatted and searched
|
||||
/// by name for every pixel.
|
||||
fn ranks_by_name(a: &AuxChannels, layer: &str, i: usize) -> Vec<(f32, f32)> {
|
||||
let mut out = vec![];
|
||||
for k in 0.. {
|
||||
let get = |c: &str| a.get(&format!("{layer}{k:02}.{c}"));
|
||||
let (Some(r), Some(g)) = (get("R"), get("G")) else { break };
|
||||
out.push((r[i], g[i]));
|
||||
if let (Some(b), Some(al)) = (get("B"), get("A")) {
|
||||
out.push((b[i], al[i]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The reduction before #473 over those ranks: (ID colours, selection coverage).
|
||||
fn reduce_by_name(a: &AuxChannels, layer: &str, sel: &[u32], i: usize) -> ([f32; 3], f32) {
|
||||
let ranks = ranks_by_name(a, layer, i);
|
||||
let m: f32 = ranks.iter().filter(|(id, c)| *c > 0.0 && sel.contains(&id.to_bits())).map(|(_, c)| *c).sum::<f32>().clamp(0.0, 1.0);
|
||||
let mut c = [0.0f32; 3];
|
||||
for (id, cov) in &ranks {
|
||||
let k = id_color(*id);
|
||||
for j in 0..3 {
|
||||
c[j] += k[j] * cov;
|
||||
}
|
||||
}
|
||||
(c, m)
|
||||
}
|
||||
|
||||
/// A 6×2 Cryptomatte layer with awkward values: NaN, ±inf, negative and -0.0 coverage, NaN
|
||||
/// and -0.0 IDs; rank 01 has no B/A pair, rank 02 is missing, so rank 03 (filled with values
|
||||
/// that would change every pixel) must be ignored as before.
|
||||
fn edge_aux(with_rank_after_gap: bool) -> AuxChannels {
|
||||
let (w, h) = (6u32, 2u32);
|
||||
let nan_payload = f32::from_bits(0x7fc0_1234);
|
||||
let (hs, hc) = (crypto_float(crypto_hash("sphere")), crypto_float(crypto_hash("cube")));
|
||||
let cov = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5, -0.0, 0.0, 0.25, 1.0, 2.0, f32::MIN_POSITIVE, nan_payload, 0.5];
|
||||
let ids = [hs, f32::NAN, hc, -0.0, 0.0, nan_payload, hs, hc, f32::NAN, hs, hc, -0.0];
|
||||
let rot = |v: &[f32; 12], k: usize| -> Vec<f32> { (0..12).map(|i| v[(i + k) % 12]).collect() };
|
||||
let mut a = AuxChannels::new(w, h, 1.0);
|
||||
let mut push = |n: &str, v: Vec<f32>| a.channels.push((format!("CryptoObject{n}"), v));
|
||||
push("00.R", rot(&ids, 0));
|
||||
push("00.G", rot(&cov, 0));
|
||||
push("00.B", rot(&ids, 3));
|
||||
push("00.A", rot(&cov, 5));
|
||||
push("01.R", rot(&ids, 7));
|
||||
push("01.G", rot(&cov, 2));
|
||||
if with_rank_after_gap {
|
||||
push("03.R", vec![hs; 12]);
|
||||
push("03.G", vec![1.0; 12]);
|
||||
push("03.B", vec![hc; 12]);
|
||||
push("03.A", vec![1.0; 12]);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values
|
||||
/// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits).
|
||||
#[test]
|
||||
fn id_color_memo_matches_id_color() {
|
||||
let a = edge_aux(true);
|
||||
let planes = a.crypto_rank_planes("CryptoObject");
|
||||
let mut memo = IdColorMemo::default();
|
||||
for i in 0..12 {
|
||||
for (r, (id, _)) in planes.iter().enumerate() {
|
||||
assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_selection() -> Vec<u32> {
|
||||
vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()]
|
||||
}
|
||||
|
||||
/// Edge values give bit-identical results to the lookup by name, the missing B/A pair is
|
||||
/// skipped and a rank after a gap is ignored.
|
||||
#[test]
|
||||
fn crypto_aux_image_is_bit_exact_on_edge_values() {
|
||||
let sel = edge_selection();
|
||||
let a = edge_aux(true);
|
||||
assert_eq!(a.crypto_rank_planes("CryptoObject").len(), 3, "00.R/G, 00.B/A, 01.R/G; not 03 after the gap");
|
||||
let img = crypto_aux_image(&a, "CryptoObject", &sel);
|
||||
let without_gap_rank = crypto_aux_image(&edge_aux(false), "CryptoObject", &sel);
|
||||
for i in 0..12 {
|
||||
let (c, m) = reduce_by_name(&a, "CryptoObject", &sel, i);
|
||||
let want = [c[0], c[1], c[2], m];
|
||||
assert_eq!(img.data[i].map(f32::to_bits), want.map(f32::to_bits), "pixel {i}");
|
||||
assert_eq!(img.data[i].map(f32::to_bits), without_gap_rank.data[i].map(f32::to_bits), "pixel {i}: the rank after the gap was used");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values.
|
||||
#[test]
|
||||
fn crypto_aux_image_without_colors_keeps_the_matte() {
|
||||
let a = edge_aux(true);
|
||||
let sel = edge_selection();
|
||||
let full = crypto_aux_image(&a, "CryptoObject", &sel);
|
||||
let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly);
|
||||
for (f, m) in full.data.iter().zip(&matte.data) {
|
||||
assert_eq!(f[3].to_bits(), m[3].to_bits());
|
||||
assert_eq!(m[..3], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the Colors display without Matte Only reads the ID colours.
|
||||
#[test]
|
||||
fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() {
|
||||
assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte);
|
||||
assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly);
|
||||
for display in [1, 2, 3, u32::MAX] {
|
||||
for matte_only in [false, true] {
|
||||
assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match
|
||||
/// the lookup by name bit for bit in every display mode, pixels outside it (no aux index)
|
||||
/// get an empty matte and no ID colour.
|
||||
#[test]
|
||||
fn cryptomatte_cpu_path_is_bit_exact_inside_and_outside_the_aux_area() {
|
||||
let a = edge_aux(true);
|
||||
let host = AuxHost(Arc::new(a.clone()));
|
||||
let names = "sphere, <7fc00000>, <7fc01234>, <80000000>";
|
||||
assert_eq!(selection_hashes(names, None), edge_selection());
|
||||
let src = img().data[0];
|
||||
for (display, matte_only) in [(0, false), (1, false), (2, false), (0, true)] {
|
||||
let out = run(
|
||||
"ec.3d.cryptomatte",
|
||||
&[("selection", Value::Str(names.into())), ("display", Value::Enum(display)), ("matteOnly", Value::Bool(matte_only))],
|
||||
&host,
|
||||
);
|
||||
let expect = |c: [f32; 3], m: f32| -> [f32; 4] {
|
||||
if matte_only {
|
||||
return [m; 4];
|
||||
}
|
||||
match display {
|
||||
0 => {
|
||||
let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m);
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
}
|
||||
1 => src.map(|v| v * m),
|
||||
_ => [src[0] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[1] * (1.0 - 0.5 * m) + 0.5 * m * src[3], src[2] * (1.0 - 0.5 * m), src[3]],
|
||||
}
|
||||
};
|
||||
for (x, y) in [(0u32, 0u32), (1, 0), (5, 0), (0, 1), (3, 1), (5, 1)] {
|
||||
let (c, m) = reduce_by_name(&a, "CryptoObject", &edge_selection(), (y * 6 + x) as usize);
|
||||
assert_eq!(out.get(x as i64, y as i64).map(f32::to_bits), expect(c, m).map(f32::to_bits), "display {display} matteOnly {matte_only} at ({x}, {y})");
|
||||
}
|
||||
for (x, y) in [(6u32, 0u32), (19, 0), (0, 2), (12, 9)] {
|
||||
assert_eq!(
|
||||
out.get(x as i64, y as i64).map(f32::to_bits),
|
||||
expect([0.0; 3], 0.0).map(f32::to_bits),
|
||||
"display {display} matteOnly {matte_only} outside the aux at ({x}, {y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cryptomatte_selects_by_name_and_wildcard() {
|
||||
let h = scene();
|
||||
let m = run("ec.3d.cryptomatte", &[("selection", Value::Str("right".into())), ("matteOnly", Value::Bool(true))], &h);
|
||||
assert_eq!(m.get(2, 5)[3], 0.0);
|
||||
assert_eq!(m.get(12, 5)[3], 1.0);
|
||||
let w = run("ec.3d.cryptomatte", &[("selection", Value::Str("l*".into())), ("display", Value::Enum(1))], &h);
|
||||
assert_eq!(w.get(2, 5)[3], 1.0);
|
||||
assert_eq!(w.get(12, 5)[3], 0.0);
|
||||
assert!(wild("*ight", "right") && wild("r*t", "right") && !wild("x*", "right"));
|
||||
let hex = format!("<{:08x}>", crypto_hash("left"));
|
||||
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
|
||||
}
|
||||
|
||||
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
|
||||
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
|
||||
#[test]
|
||||
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
|
||||
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
|
||||
let m = Some(manifest.as_slice());
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
|
||||
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
|
||||
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
|
||||
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
|
||||
// Without a manifest the whole name is hashed.
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
|
||||
// Plain lists, apostrophes inside names and an unclosed quote still parse.
|
||||
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
|
||||
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
|
||||
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
|
||||
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fog_and_depth_of_field_follow_depth() {
|
||||
let h = scene();
|
||||
let f = run("ec.3d.fog3d", &[("fogColor", col(1.0, 0.0, 0.0)), ("fogEndDepth", num(1000.0)), ("scatteringDensity", num(0.0))], &h);
|
||||
// Linear fog: 10 % at depth 100, 50 % at 500, full on the background.
|
||||
assert!((f.get(2, 5)[0] - 0.55).abs() < 1e-4, "{:?}", f.get(2, 5));
|
||||
assert!((f.get(12, 5)[0] - 0.75).abs() < 1e-4);
|
||||
assert!((f.get(17, 1)[0] - 1.0).abs() < 1e-4 && f.get(17, 1)[1] < 1e-4);
|
||||
assert!((dof_radius(100.0, 100.0, 0.0, 10.0, 50.0)).abs() < 1e-6);
|
||||
assert!((dof_radius(200.0, 100.0, 0.0, 10.0, 50.0) - 10.0).abs() < 1e-6);
|
||||
assert_eq!(dof_radius(140.0, 100.0, 100.0, 10.0, 50.0), 0.0);
|
||||
// A sharp edge at the far object blurs when focused near.
|
||||
let mut pic = Image::new(20, 10);
|
||||
for y in 0..10 {
|
||||
for x in 0..20 {
|
||||
let v = if x % 2 == 0 { 1.0 } else { 0.0 };
|
||||
pic.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let env = EffectEnv { host: Some(&h), ..Default::default() };
|
||||
let d = run_fx("ec.3d.depthoffield", &[("focalPlane", num(100.0)), ("maximumRadius", num(4.0))], pic.clone(), 0.0, env).img;
|
||||
assert_eq!(d.get(4, 5), pic.get(4, 5));
|
||||
assert!((d.get(12, 5)[0] - 0.5).abs() < 0.3 && d.get(12, 5) != pic.get(12, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extractor_maps_named_channels() {
|
||||
let h = scene();
|
||||
let o = run("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("whitePoint", num(1000.0))], &h);
|
||||
assert!((o.get(2, 5)[0] - 0.1).abs() < 1e-6);
|
||||
assert!((o.get(2, 5)[1] - 0.0005).abs() < 1e-6);
|
||||
// Without aux: the layer's own channels can be swapped.
|
||||
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
let mut a = AuxChannels::new(40, 20, 2.0);
|
||||
a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
|
||||
let hard = run("ec.3d.channelextract", &vals, &host);
|
||||
let v = hard.get(5, 5)[0];
|
||||
assert!(v == 0.0 || v == 1.0, "{v}");
|
||||
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
|
||||
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance3d_replaces_the_pixels_with_the_referenced_layer() {
|
||||
// Create 3D Layer Instance's copy: the chosen layer's render takes the place of the layer's
|
||||
// own pixels, alpha included — the copy is transparent where the referenced layer isn't —
|
||||
// and with no layer chosen the layer passes through.
|
||||
struct Host(Image);
|
||||
impl EffectHost for Host {
|
||||
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
|
||||
(id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let solid = || Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
let mut render = Image::new(4, 4);
|
||||
render.set(1, 1, [1.0, 0.0, 0.0, 1.0]);
|
||||
let env = EffectEnv { host: Some(&Host(render)), ..Default::default() };
|
||||
let out = run_fx("ec.3d.instance3d", &[("layer", Value::Layer(Some(7)))], solid(), 0.0, env);
|
||||
assert_eq!(out.img.get(1, 1), [1.0, 0.0, 0.0, 1.0], "the referenced layer's pixel");
|
||||
assert_eq!(out.img.get(3, 3), [0.0; 4], "transparent where the referenced layer isn't");
|
||||
// No layer chosen: the layer's own pixels.
|
||||
let out = run_fx("ec.3d.instance3d", &[], solid(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, solid().data);
|
||||
}
|
||||
+58
-267
@@ -55,6 +55,17 @@ impl Curve {
|
||||
/// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or
|
||||
/// fewer than two valid points.
|
||||
pub fn parse(s: &str) -> Option<Curve> {
|
||||
Self::parse_with(s, false)
|
||||
}
|
||||
|
||||
/// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline
|
||||
/// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and
|
||||
/// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does).
|
||||
pub fn parse_natural(s: &str) -> Option<Curve> {
|
||||
Self::parse_with(s, true)
|
||||
}
|
||||
|
||||
fn parse_with(s: &str, natural: bool) -> Option<Curve> {
|
||||
let mut pts: Vec<(f32, f32)> = s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter(|t| !t.is_empty())
|
||||
@@ -74,6 +85,12 @@ impl Curve {
|
||||
let ys: Vec<f32> = pts.iter().map(|p| p.1).collect();
|
||||
let n = xs.len();
|
||||
let d: Vec<f32> = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect();
|
||||
if natural {
|
||||
let ms = natural_slopes(&xs, &d);
|
||||
let mut c = Curve { xs, ys, ms, lut: Vec::new() };
|
||||
c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect();
|
||||
return Some(c);
|
||||
}
|
||||
let mut ms = vec![0.0f32; n];
|
||||
ms[0] = d[0];
|
||||
ms[n - 1] = d[n - 2];
|
||||
@@ -136,6 +153,40 @@ impl Curve {
|
||||
}
|
||||
}
|
||||
|
||||
/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with
|
||||
/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly.
|
||||
fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec<f32> {
|
||||
let n = xs.len();
|
||||
if n < 3 {
|
||||
return vec![d.first().copied().unwrap_or(0.0); n];
|
||||
}
|
||||
let h: Vec<f64> = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect();
|
||||
let dd: Vec<f64> = d.iter().map(|&v| f64::from(v)).collect();
|
||||
// Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm.
|
||||
let m = n - 2;
|
||||
let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]);
|
||||
for i in 0..m {
|
||||
let k = i + 1;
|
||||
a[i] = h[k - 1];
|
||||
b[i] = 2.0 * (h[k - 1] + h[k]);
|
||||
c[i] = h[k];
|
||||
r[i] = 6.0 * (dd[k] - dd[k - 1]);
|
||||
}
|
||||
for i in 1..m {
|
||||
let w = a[i] / b[i - 1];
|
||||
b[i] -= w * c[i - 1];
|
||||
r[i] -= w * r[i - 1];
|
||||
}
|
||||
let mut sec = vec![0.0f64; n];
|
||||
for i in (0..m).rev() {
|
||||
let next = if i + 1 < m { sec[i + 2] } else { 0.0 };
|
||||
sec[i + 1] = (r[i] - c[i] * next) / b[i];
|
||||
}
|
||||
let mut ms: Vec<f32> = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect();
|
||||
ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32);
|
||||
ms
|
||||
}
|
||||
|
||||
fn ev(c: &Option<Curve>, v: f32) -> f32 {
|
||||
c.as_ref().map(|c| c.eval(v)).unwrap_or(v)
|
||||
}
|
||||
@@ -145,7 +196,7 @@ fn ev(c: &Option<Curve>, v: f32) -> f32 {
|
||||
/// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the
|
||||
/// master RGB curve first, then their own curve.
|
||||
fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id)));
|
||||
let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id)));
|
||||
if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() {
|
||||
return b;
|
||||
}
|
||||
@@ -201,7 +252,7 @@ impl Lv {
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01))
|
||||
self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
return b;
|
||||
}
|
||||
let clip = crate::color_fx::levels_clip(ctx);
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
// Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB
|
||||
// (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green
|
||||
// 52/255 (RGB first would give 45).
|
||||
map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0)));
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1075,267 +1129,4 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Params, find};
|
||||
|
||||
fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image {
|
||||
let s = find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in over {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx =
|
||||
EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn ramp() -> Image {
|
||||
let mut img = Image::new(16, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
let a = if y == 0 { 0.5 } else { 1.0 };
|
||||
img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn close(a: &Image, b: &Image, eps: f32) -> bool {
|
||||
a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_parsing() {
|
||||
assert!(Curve::parse("0,0 1,1").is_none());
|
||||
assert!(Curve::parse("garbage").is_none());
|
||||
let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap();
|
||||
assert_eq!(c.xs, vec![0.0, 0.5, 1.0]);
|
||||
assert!((c.eval(0.5) - 0.7).abs() < 1e-3);
|
||||
// Monotone: never decreases.
|
||||
let mut last = -1.0;
|
||||
for i in 0..=100 {
|
||||
let v = c.eval(i as f32 / 100.0);
|
||||
assert!(v >= last - 1e-6);
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_identity_is_identity() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.curves", &img, &[]), img);
|
||||
let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]);
|
||||
assert!(close(&explicit, &img, 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_midpoint_brightens() {
|
||||
let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]);
|
||||
let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]);
|
||||
assert!((out.data[0][0] - 0.75).abs() < 1e-3);
|
||||
let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]);
|
||||
assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_ic_identity_and_red_only() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.levelsic", &img, &[]), img);
|
||||
let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]);
|
||||
let p = out.get(15, 3);
|
||||
assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_levels_stretches_low_contrast() {
|
||||
let mut img = Image::new(32, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..32 {
|
||||
let v = 0.4 + 0.2 * x as f32 / 31.0;
|
||||
img.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
|
||||
let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min);
|
||||
let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max);
|
||||
assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}");
|
||||
let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
|
||||
assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_color_neutralises_cast() {
|
||||
let mut img = Image::new(16, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run("ec.color.autocolor", &img, &[]);
|
||||
let p = out.get(8, 1);
|
||||
assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equalize_flattens_histogram() {
|
||||
let mut img = Image::new(64, 1);
|
||||
for x in 0..64 {
|
||||
let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 };
|
||||
img.set(x, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let out = run("ec.color.equalize", &img, &[]);
|
||||
// The crowded dark values are spread across the range.
|
||||
assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]);
|
||||
assert!(out.get(0, 0)[0] < 0.1);
|
||||
assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_zero_is_identity_and_reds_respond() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
|
||||
let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
|
||||
let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
|
||||
assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
|
||||
let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
|
||||
assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_offset_full_turn_is_identity() {
|
||||
let img = ramp();
|
||||
let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]);
|
||||
assert_eq!(out, img);
|
||||
let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]);
|
||||
assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_reduces_unsafe_color() {
|
||||
let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]);
|
||||
let out = run("ec.color.broadcast", &img, &[]);
|
||||
assert!(out.data[0][0] < 1.0);
|
||||
let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]);
|
||||
assert_eq!(key.data[0][3], 0.0);
|
||||
let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(run("ec.color.broadcast", &gray, &[]), gray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_hls_zero_identity_and_hue_shift() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img);
|
||||
let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]);
|
||||
let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]);
|
||||
assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_link_fills_with_average_keeping_alpha() {
|
||||
let mut img = Image::new(2, 1);
|
||||
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
|
||||
img.set(1, 0, [0.0, 0.0, 0.5, 0.5]);
|
||||
let out = run("ec.color.colorlink", &img, &[]);
|
||||
assert!((out.data[1][3] - 0.5).abs() < 1e-5);
|
||||
let (c, _) = unpremul(out.data[0]);
|
||||
assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_kernel_identity_and_toner_duotone() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.cckernel", &img, &[]), img);
|
||||
let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]);
|
||||
let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]);
|
||||
assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_highlight_lifts_shadows() {
|
||||
let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]);
|
||||
let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]);
|
||||
assert!(out.data[0][0] > 0.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_ic_clip_popups() {
|
||||
let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]);
|
||||
let lv = [("rgb/rgbInWhite", num(0.5))];
|
||||
assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6);
|
||||
let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))];
|
||||
assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5);
|
||||
}
|
||||
|
||||
/// A host whose other layers and other frames are a fixed image.
|
||||
struct ImgHost(Image);
|
||||
impl crate::EffectHost for ImgHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, _: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image {
|
||||
let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
|
||||
crate::run_fx(id, over, img.clone(), 1.0, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_link_samples_clip_and_source_layer() {
|
||||
let mut img = Image::new(5, 1);
|
||||
for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() {
|
||||
img.set(x as u32, 0, [*v, *v, *v, 1.0]);
|
||||
}
|
||||
// Median ignores the outlier the average follows.
|
||||
let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]);
|
||||
assert!((med.data[0][0] - 0.2).abs() < 1e-5);
|
||||
// Brightest with 20 % clip skips the brightest pixel.
|
||||
let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]);
|
||||
assert!((br.data[0][0] - 0.3).abs() < 1e-5);
|
||||
// Min Alpha of a half-transparent source layer sets the alpha.
|
||||
let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5]));
|
||||
let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host);
|
||||
assert!((out.data[4][3] - 0.5).abs() < 1e-5);
|
||||
// Average of the source layer (not the effect's own pixels).
|
||||
let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host);
|
||||
assert!((out.data[4][0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_levels_temporal_smoothing_reads_neighbour_frames() {
|
||||
let mut img = Image::new(4, 1);
|
||||
for x in 0..4 {
|
||||
let v = 0.3 + 0.1 * x as f32;
|
||||
img.set(x, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let wide = ImgHost(ramp());
|
||||
let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))];
|
||||
let alone = run_host("ec.color.autolevels", &img, &p0, &wide);
|
||||
let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide);
|
||||
// Alone the frame is stretched to full range; with full-range neighbours much less.
|
||||
assert!(alone.data[0][0] < 0.01);
|
||||
assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_highlight_clip_keeps_the_tonal_range() {
|
||||
let img = ramp();
|
||||
let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))];
|
||||
let unclipped = run(
|
||||
"ec.color.shadowhighlight",
|
||||
&img,
|
||||
&[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))],
|
||||
);
|
||||
let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]);
|
||||
let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min);
|
||||
assert!(min(&clipped) < min(&unclipped));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,338 @@
|
||||
use super::*;
|
||||
use crate::{Params, find};
|
||||
|
||||
fn run(id: &str, img: &Image, over: &[(&str, Value)]) -> Image {
|
||||
let s = find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in over {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
fn ramp() -> Image {
|
||||
let mut img = Image::new(16, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
let a = if y == 0 { 0.5 } else { 1.0 };
|
||||
img.set(x, y, [v * a, (1.0 - v) * a, 0.3 * a, a]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn close(a: &Image, b: &Image, eps: f32) -> bool {
|
||||
a.data.iter().zip(&b.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() <= eps))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_parsing() {
|
||||
assert!(Curve::parse("0,0 1,1").is_none());
|
||||
assert!(Curve::parse("garbage").is_none());
|
||||
let c = Curve::parse("1,1; 0,0 0.5,0.7").unwrap();
|
||||
assert_eq!(c.xs, vec![0.0, 0.5, 1.0]);
|
||||
assert!((c.eval(0.5) - 0.7).abs() < 1e-3);
|
||||
// Monotone: never decreases.
|
||||
let mut last = -1.0;
|
||||
for i in 0..=100 {
|
||||
let v = c.eval(i as f32 / 100.0);
|
||||
assert!(v >= last - 1e-6);
|
||||
last = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_identity_is_identity() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.curves", &img, &[]), img);
|
||||
let explicit = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.25,0.25 1,1".into()))]);
|
||||
assert!(close(&explicit, &img, 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_midpoint_brightens() {
|
||||
let img = Image::filled(4, 4, [0.5, 0.5, 0.5, 1.0]);
|
||||
let out = run("ec.color.curves", &img, &[("rgb", Value::Str("0,0 0.5,0.75 1,1".into()))]);
|
||||
assert!((out.data[0][0] - 0.75).abs() < 1e-3);
|
||||
let red = run("ec.color.curves", &img, &[("red", Value::Str("0,1 1,1".into()))]);
|
||||
assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1,
|
||||
/// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green
|
||||
/// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps:
|
||||
/// the channel controls run first, nothing clips (8-bit captures clamp the results).
|
||||
#[test]
|
||||
fn levels_ic_matches_after_effects_in_32_bpc() {
|
||||
let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
let ae = [
|
||||
[0, 0, 70],
|
||||
[0, 0, 88],
|
||||
[0, 52, 104],
|
||||
[61, 108, 132],
|
||||
[102, 150, 158],
|
||||
[138, 187, 181],
|
||||
[171, 220, 203],
|
||||
[203, 250, 223],
|
||||
[233, 255, 243],
|
||||
[255, 255, 255],
|
||||
];
|
||||
let mut img = Image::new(10, 1);
|
||||
for (x, v) in steps.iter().enumerate() {
|
||||
let v = *v as f32 / 255.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let vals = [
|
||||
("rgb/rgbInBlack", Value::Scalar(0.1)),
|
||||
("rgb/rgbInWhite", Value::Scalar(0.9)),
|
||||
("rgb/rgbGamma", Value::Scalar(1.5)),
|
||||
("rgb/rgbOutBlack", Value::Scalar(0.05)),
|
||||
("rgb/rgbOutWhite", Value::Scalar(0.95)),
|
||||
("red/redGamma", Value::Scalar(0.8)),
|
||||
("green/greenInWhite", Value::Scalar(0.8)),
|
||||
("blue/blueOutBlack", Value::Scalar(0.2)),
|
||||
];
|
||||
let s = find("ec.color.levelsic").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in &vals {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let render = |float_depth: bool| {
|
||||
let env = crate::EffectEnv { float_depth, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
};
|
||||
let out = render(true);
|
||||
for (x, want) in ae.iter().enumerate() {
|
||||
let p = out.get(x as i64, 0);
|
||||
for c in 0..3 {
|
||||
let got = p[c].clamp(0.0, 1.0) * 255.0;
|
||||
assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]);
|
||||
}
|
||||
}
|
||||
// In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black.
|
||||
let clipped = render(false);
|
||||
assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0));
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199)
|
||||
/// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results.
|
||||
#[test]
|
||||
fn curves_follow_a_natural_spline_as_in_after_effects() {
|
||||
let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255];
|
||||
let mut img = Image::new(10, 1);
|
||||
for (x, v) in steps.iter().enumerate() {
|
||||
let v = *v as f32 / 255.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::<Vec<_>>().join(" "));
|
||||
let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]);
|
||||
let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]);
|
||||
let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255];
|
||||
let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255];
|
||||
for x in 0..10 {
|
||||
let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32;
|
||||
let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32;
|
||||
assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]);
|
||||
assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_ic_identity_and_red_only() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.levelsic", &img, &[]), img);
|
||||
let out = run("ec.color.levelsic", &img, &[("red/redOutWhite", num(0.5))]);
|
||||
let p = out.get(15, 3);
|
||||
assert!((p[0] - 0.5).abs() < 1e-4 && (p[1] - 0.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_levels_stretches_low_contrast() {
|
||||
let mut img = Image::new(32, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..32 {
|
||||
let v = 0.4 + 0.2 * x as f32 / 31.0;
|
||||
img.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run("ec.color.autolevels", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
|
||||
let lo = out.data.iter().map(|p| p[0]).fold(1.0, f32::min);
|
||||
let hi = out.data.iter().map(|p| p[0]).fold(0.0, f32::max);
|
||||
assert!(lo < 0.02 && hi > 0.98, "{lo} {hi}");
|
||||
let ac = run("ec.color.autocontrast", &img, &[("blackClip", num(0.0)), ("whiteClip", num(0.0))]);
|
||||
assert!(ac.data.iter().map(|p| p[0]).fold(0.0, f32::max) > 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_color_neutralises_cast() {
|
||||
let mut img = Image::new(16, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
img.set(x, y, [v * 0.6 + 0.1, v * 0.9, v * 0.5, 1.0]);
|
||||
}
|
||||
}
|
||||
let out = run("ec.color.autocolor", &img, &[]);
|
||||
let p = out.get(8, 1);
|
||||
assert!((p[0] - p[1]).abs() < 0.08 && (p[1] - p[2]).abs() < 0.08, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equalize_flattens_histogram() {
|
||||
let mut img = Image::new(64, 1);
|
||||
for x in 0..64 {
|
||||
let v = if x < 48 { 0.1 + x as f32 * 0.001 } else { 0.9 };
|
||||
img.set(x, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let out = run("ec.color.equalize", &img, &[]);
|
||||
// The crowded dark values are spread across the range.
|
||||
assert!(out.get(47, 0)[0] > 0.6, "{}", out.get(47, 0)[0]);
|
||||
assert!(out.get(0, 0)[0] < 0.1);
|
||||
assert_eq!(run("ec.color.equalize", &img, &[("amount", num(0.0))]), img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_zero_is_identity_and_reds_respond() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
|
||||
let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
|
||||
let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
|
||||
assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
|
||||
let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
|
||||
assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_offset_full_turn_is_identity() {
|
||||
let img = ramp();
|
||||
let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(360.0)), ("greenPhase", num(720.0))]);
|
||||
assert_eq!(out, img);
|
||||
let out = run("ec.color.cccoloroffset", &img, &[("redPhase", num(180.0))]);
|
||||
assert!((out.get(0, 3)[0] - 0.5).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_reduces_unsafe_color() {
|
||||
let img = Image::filled(2, 2, [1.0, 1.0, 0.0, 1.0]);
|
||||
let out = run("ec.color.broadcast", &img, &[]);
|
||||
assert!(out.data[0][0] < 1.0);
|
||||
let key = run("ec.color.broadcast", &img, &[("howToMakeSafe", Value::Enum(2))]);
|
||||
assert_eq!(key.data[0][3], 0.0);
|
||||
let gray = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(run("ec.color.broadcast", &gray, &[]), gray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_hls_zero_identity_and_hue_shift() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.colorbalancehls", &img, &[]), img);
|
||||
let red = Image::filled(1, 1, [1.0, 0.0, 0.0, 1.0]);
|
||||
let out = run("ec.color.colorbalancehls", &red, &[("hue", num(120.0))]);
|
||||
assert!(out.data[0][1] > 0.99 && out.data[0][0] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_link_fills_with_average_keeping_alpha() {
|
||||
let mut img = Image::new(2, 1);
|
||||
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
|
||||
img.set(1, 0, [0.0, 0.0, 0.5, 0.5]);
|
||||
let out = run("ec.color.colorlink", &img, &[]);
|
||||
assert!((out.data[1][3] - 0.5).abs() < 1e-5);
|
||||
let (c, _) = unpremul(out.data[0]);
|
||||
assert!((c[0] - 2.0 / 3.0).abs() < 1e-3 && (c[2] - 1.0 / 3.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cc_kernel_identity_and_toner_duotone() {
|
||||
let img = ramp();
|
||||
assert_eq!(run("ec.color.cckernel", &img, &[]), img);
|
||||
let g = Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]);
|
||||
let t = run("ec.color.cctoner", &g, &[("tones", Value::Enum(0)), ("highlights", col(1.0, 0.0, 0.0))]);
|
||||
assert!((t.data[0][0] - 0.5).abs() < 1e-3 && t.data[0][1] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_highlight_lifts_shadows() {
|
||||
let img = Image::filled(8, 8, [0.1, 0.1, 0.1, 1.0]);
|
||||
let out = run("ec.color.shadowhighlight", &img, &[("autoAmounts", Value::Bool(false))]);
|
||||
assert!(out.data[0][0] > 0.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_ic_clip_popups() {
|
||||
let img = Image::filled(2, 2, [0.9, 0.9, 0.9, 1.0]);
|
||||
let lv = [("rgb/rgbInWhite", num(0.5))];
|
||||
assert!((run("ec.color.levelsic", &img, &lv).data[0][0] - 1.0).abs() < 1e-6);
|
||||
let off = [("rgb/rgbInWhite", num(0.5)), ("clipToOutputWhite", Value::Enum(0))];
|
||||
assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5);
|
||||
}
|
||||
|
||||
/// A host whose other layers and other frames are a fixed image.
|
||||
struct ImgHost(Image);
|
||||
impl crate::EffectHost for ImgHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [2.0, 2.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, _: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
fn run_host(id: &str, img: &Image, over: &[(&str, Value)], host: &ImgHost) -> Image {
|
||||
let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
|
||||
crate::run_fx(id, over, img.clone(), 1.0, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_link_samples_clip_and_source_layer() {
|
||||
let mut img = Image::new(5, 1);
|
||||
for (x, v) in [0.0f32, 0.1, 0.2, 0.3, 1.0].iter().enumerate() {
|
||||
img.set(x as u32, 0, [*v, *v, *v, 1.0]);
|
||||
}
|
||||
// Median ignores the outlier the average follows.
|
||||
let med = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(1))]);
|
||||
assert!((med.data[0][0] - 0.2).abs() < 1e-5);
|
||||
// Brightest with 20 % clip skips the brightest pixel.
|
||||
let br = run("ec.color.colorlink", &img, &[("sampleSource", Value::Enum(2)), ("clip", num(20.0))]);
|
||||
assert!((br.data[0][0] - 0.3).abs() < 1e-5);
|
||||
// Min Alpha of a half-transparent source layer sets the alpha.
|
||||
let host = ImgHost(Image::filled(2, 2, [0.25, 0.25, 0.25, 0.5]));
|
||||
let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", Value::Enum(9))], &host);
|
||||
assert!((out.data[4][3] - 0.5).abs() < 1e-5);
|
||||
// Average of the source layer (not the effect's own pixels).
|
||||
let out = run_host("ec.color.colorlink", &img, &[("sourceLayer", Value::Layer(Some(9)))], &host);
|
||||
assert!((out.data[4][0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_levels_temporal_smoothing_reads_neighbour_frames() {
|
||||
let mut img = Image::new(4, 1);
|
||||
for x in 0..4 {
|
||||
let v = 0.3 + 0.1 * x as f32;
|
||||
img.set(x, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let wide = ImgHost(ramp());
|
||||
let p0 = [("blackClip", num(0.0)), ("whiteClip", num(0.0))];
|
||||
let alone = run_host("ec.color.autolevels", &img, &p0, &wide);
|
||||
let smooth = run_host("ec.color.autolevels", &img, &[p0[0].clone(), p0[1].clone(), ("temporalSmoothing", num(0.2))], &wide);
|
||||
// Alone the frame is stretched to full range; with full-range neighbours much less.
|
||||
assert!(alone.data[0][0] < 0.01);
|
||||
assert!(smooth.data[0][0] > 0.1, "{:?}", smooth.data[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_highlight_clip_keeps_the_tonal_range() {
|
||||
let img = ramp();
|
||||
let lift = [("autoAmounts", Value::Bool(false)), ("shadowAmount", num(80.0))];
|
||||
let unclipped =
|
||||
run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(0.0)), ("moreOptions/whiteClip", num(0.0))]);
|
||||
let clipped = run("ec.color.shadowhighlight", &img, &[lift[0].clone(), lift[1].clone(), ("moreOptions/blackClip", num(1.0))]);
|
||||
let min = |im: &Image| im.data.iter().filter(|p| p[3] > 0.99).map(|p| luminance(p[0], p[1], p[2])).fold(f32::MAX, f32::min);
|
||||
assert!(min(&clipped) < min(&unclipped));
|
||||
}
|
||||
@@ -301,7 +301,8 @@ impl OffsetCurve {
|
||||
}
|
||||
let n = ext.len();
|
||||
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
|
||||
// segments so the curve never overshoots its points.
|
||||
// segments, and at most three times either slope (Fritsch-Carlson) so the curve never
|
||||
// overshoots its points, even beside a short steep segment.
|
||||
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
|
||||
let m: Vec<f32> = (0..n)
|
||||
.map(|k| {
|
||||
@@ -309,7 +310,12 @@ impl OffsetCurve {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (slope(k - 1), slope(k));
|
||||
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
|
||||
if a * b <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
let lim = 3.0 * a.abs().min(b.abs());
|
||||
((a + b) * 0.5).clamp(-lim, lim)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let eval = |x: f32| -> f32 {
|
||||
@@ -989,6 +995,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_curve_stays_between_its_points() {
|
||||
// #1656: a point dragged to the bottom right next to another one. The steep short
|
||||
// segment used to pull the long one beside it far outside the box (|offset| > 0.5).
|
||||
// The third pair sits across the hue wrap (0.98 and 0.005 are neighbours).
|
||||
let cases = [("0.3,0.4 0.31,0 0.7,0.6", true), ("0.1,0.3 0.3,0.5 0.31,1 0.7,0.5", false), ("0.5,0.6 0.98,0.4 0.005,0", true)];
|
||||
for (pts, periodic) in cases {
|
||||
let c = OffsetCurve::parse(pts, periodic).unwrap();
|
||||
for i in 0..=1000 {
|
||||
let x = i as f32 / 1000.0;
|
||||
let v = c.at(x);
|
||||
assert!((-0.5..=0.5).contains(&v), "{pts} at {x}: {v}");
|
||||
}
|
||||
for p in parse_points(pts) {
|
||||
assert!((c.at(p.0) - (p.1 - 0.5)).abs() < 0.02, "{pts}: passes through {p:?}");
|
||||
}
|
||||
if periodic {
|
||||
assert!((c.at(0.0) - c.at(1.0)).abs() < 1e-4, "{pts}: no seam at the wrap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
+104
-390
@@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
|
||||
}
|
||||
|
||||
/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red
|
||||
/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and
|
||||
/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from
|
||||
/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree
|
||||
/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on
|
||||
/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green
|
||||
/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it.
|
||||
pub(crate) fn luma_601(c: [f32; 3]) -> f32 {
|
||||
LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2]
|
||||
}
|
||||
|
||||
/// The Rec. 601 luma weights of `luma_601`. The WGSL kernels repeat them as literals; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let black = ctx.params.color("black");
|
||||
let white = ctx.params.color("white");
|
||||
let amt = ctx.params.f("amount") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l];
|
||||
mix(c, t, amt)
|
||||
});
|
||||
@@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let sh = ctx.params.color("shadows");
|
||||
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
|
||||
b.img.map_straight(|c| {
|
||||
let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
let l = luma_601(c).clamp(0.0, 1.0);
|
||||
let t = if l < 0.5 {
|
||||
mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0)
|
||||
} else {
|
||||
@@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
|
||||
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
|
||||
/// pass through. The GPU kernel mirrors it.
|
||||
///
|
||||
/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects
|
||||
/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise),
|
||||
/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let mut v = v.max(0.0);
|
||||
if b > 0.0 {
|
||||
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
|
||||
} else if b < 0.0 {
|
||||
v = v.powf(2f32.powf(-1.32 * b));
|
||||
if b != 0.0 {
|
||||
let e = 2f32.powf(1.24 * b.abs());
|
||||
v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e });
|
||||
}
|
||||
if c != 0.0 {
|
||||
let g = (1.0 + 0.53 * c).max(0.05);
|
||||
let g = (1.0 + 0.53 * c.abs()).max(0.05);
|
||||
let g = if c > 0.0 { g } else { 1.0 / g };
|
||||
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
|
||||
}
|
||||
v
|
||||
@@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"];
|
||||
|
||||
/// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips
|
||||
/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch
|
||||
/// of projects saved before the two popups existed turns both off.
|
||||
/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual
|
||||
/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden
|
||||
/// `noClip` switch of projects saved before the two popups existed turns both off.
|
||||
pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
|
||||
if ctx.params.b("noClip") {
|
||||
return (false, false);
|
||||
}
|
||||
let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: &str| levels_clip_on(ctx, id);
|
||||
(on("clipToOutputBlack"), on("clipToOutputWhite"))
|
||||
}
|
||||
|
||||
/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip).
|
||||
pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool {
|
||||
match ctx.params.get(id).map(|v| v.as_enum()) {
|
||||
Some(0) => false,
|
||||
Some(2) => !ctx.env.float_depth,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate
|
||||
/// (the curve mirrored through 0) instead of stopping at Output Black.
|
||||
pub fn levels_gamma(t: f32, g: f32) -> f32 {
|
||||
if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) }
|
||||
}
|
||||
|
||||
/// Levels' Channel popup.
|
||||
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
|
||||
|
||||
@@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool))
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
let t = t.max(0.0).powf(1.0 / g);
|
||||
ob + (ow - ob) * t
|
||||
ob + (ow - ob) * levels_gamma(t, g)
|
||||
}
|
||||
|
||||
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
|
||||
@@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4]
|
||||
let ids = levels_channel_ids(i + 1);
|
||||
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
|
||||
let pre = LEVELS_CHANNEL_PREFIX[i];
|
||||
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
|
||||
let on = |id: String| levels_clip_on(ctx, &id);
|
||||
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
|
||||
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
|
||||
})
|
||||
@@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion
|
||||
/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005
|
||||
/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65).
|
||||
/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it.
|
||||
pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2;
|
||||
|
||||
/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless
|
||||
/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve
|
||||
/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it
|
||||
/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a
|
||||
/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past
|
||||
/// black and the default settings leave every value unchanged.
|
||||
fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let s = exposure_settings(ctx);
|
||||
let bypass = ctx.params.b("bypassLinearLight");
|
||||
@@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if bypass {
|
||||
return mirror_pow(c[i] * e + off, 1.0 / g);
|
||||
}
|
||||
let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0));
|
||||
let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA);
|
||||
let o = mirror_pow(lin * e + off, 1.0 / g);
|
||||
effectcraft_color::linear_to_srgb(o.abs()).copysign(o)
|
||||
mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA)
|
||||
})
|
||||
});
|
||||
b
|
||||
@@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 {
|
||||
/// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted
|
||||
/// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright
|
||||
/// and black blocks (#1463), and 32-bpc overbrights divided by zero.
|
||||
/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a
|
||||
/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled
|
||||
/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid
|
||||
/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2),
|
||||
/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first.
|
||||
/// The luminosity weights of `bw_tint` (a Color blend's). `fx_color.wgsl` repeats them; the GPU
|
||||
/// test `shader_constants_match_the_cpu` checks that they are these.
|
||||
pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11];
|
||||
|
||||
pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] {
|
||||
let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2];
|
||||
if !gray.is_finite() || gray <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
if gray >= 1.0 {
|
||||
return [gray; 3];
|
||||
}
|
||||
let d = gray - lum(tint);
|
||||
let c = tint.map(|v| v + d);
|
||||
let l = lum(c);
|
||||
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
|
||||
let mut o = c;
|
||||
if n < 0.0 && l - n > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * l / (l - n));
|
||||
}
|
||||
if x > 1.0 && x - l > 1e-9 {
|
||||
o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")]
|
||||
.map(|v| v as f32 / 100.0);
|
||||
@@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f;
|
||||
let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2]));
|
||||
let gray = (min + (max - min) * wt).max(0.0);
|
||||
if tint_on {
|
||||
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
|
||||
let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0));
|
||||
[r, g, bl]
|
||||
} else {
|
||||
[gray, gray, gray]
|
||||
}
|
||||
if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] }
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
});
|
||||
return b;
|
||||
}
|
||||
// Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After
|
||||
// Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with
|
||||
// nothing of the original showing through (it is not a mix with the original).
|
||||
b.img.data.iter_mut().for_each(|px| {
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] };
|
||||
let f = [c[0] * a, c[1] * a, c[2] * a];
|
||||
for i in 0..3 {
|
||||
px[i] += (f[i] - px[i]) * opacity;
|
||||
}
|
||||
if invert {
|
||||
px[3] += (a - px[3]) * opacity;
|
||||
}
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] } * opacity;
|
||||
*px = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -687,7 +755,7 @@ impl ColoramaPalette {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
|
||||
/// Parse "pos:r,g,b\[,a\] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
|
||||
pub fn parse(s: &str) -> Option<ColoramaPalette> {
|
||||
let mut stops: Vec<(f32, [f32; 4])> = s
|
||||
.split_whitespace()
|
||||
@@ -969,11 +1037,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"Exposure",
|
||||
{
|
||||
let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))];
|
||||
// After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from
|
||||
// older projects or Photoshop imports (0.01..9.99) are kept and still render.
|
||||
let group = |ids: [&'static str; 3]| {
|
||||
[
|
||||
p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
|
||||
p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)),
|
||||
p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)),
|
||||
p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)),
|
||||
]
|
||||
};
|
||||
v.extend(group(["master/exposure", "master/offset", "master/gamma"]));
|
||||
@@ -1145,360 +1215,4 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, run_fx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] {
|
||||
run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0)
|
||||
}
|
||||
fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
/// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before.
|
||||
#[test]
|
||||
fn old_saves_get_legacy_values_for_added_params() {
|
||||
use effectcraft_project::build::Ids;
|
||||
let mut next = 1;
|
||||
for (id, param, want) in
|
||||
[("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))]
|
||||
{
|
||||
let spec = crate::find(id).unwrap();
|
||||
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
|
||||
g.children.retain(|c| c.match_id() != param);
|
||||
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
|
||||
assert_eq!(g.get(param).unwrap().value, want, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_reads_get_phase_from() {
|
||||
// Same intensity, different red: Intensity gives one colour, Red gives two.
|
||||
let a = [0.6, 0.2, 0.2, 1.0];
|
||||
let b = [0.2, 0.2, 0.6, 1.0];
|
||||
let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c);
|
||||
assert!(!close(red(a), red(b), 1e-3));
|
||||
// Zero maps everything to the start of the cycle (red).
|
||||
assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// Interpolate Palette off posterizes: two nearby inputs land on the same colour.
|
||||
let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]);
|
||||
assert_eq!(off(0.20), off(0.22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() {
|
||||
let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0];
|
||||
let up = [("brightness", num(50.0)), ("contrast", num(40.0))];
|
||||
assert_eq!(bc(&up, 0.0), 0.0);
|
||||
assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6);
|
||||
assert!(bc(&up, 0.4) > 0.4);
|
||||
let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))];
|
||||
assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5);
|
||||
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
|
||||
#[test]
|
||||
fn brightness_contrast_modern_matches_ae_ramp_samples() {
|
||||
let u8 = |v: f32| (v * 255.0).round() as i32;
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
|
||||
[0])
|
||||
};
|
||||
// (brightness, contrast, input, AE 8-bit)
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(0.0, 100.0, 64, 40),
|
||||
(0.0, 100.0, 128, 129),
|
||||
(0.0, 100.0, 192, 217),
|
||||
(0.0, 50.0, 64, 53),
|
||||
(0.0, 50.0, 192, 205),
|
||||
(0.0, -50.0, 64, 77),
|
||||
(100.0, 0.0, 128, 209),
|
||||
(50.0, 0.0, 128, 171),
|
||||
(106.0, -18.0, 128, 214),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
let gray = |v: f32| [v, v, v, 1.0];
|
||||
// The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
|
||||
let hues = [
|
||||
([1.0, 0.0, 0.0], 0.4),
|
||||
([1.0, 1.0, 0.0], 0.6),
|
||||
([0.0, 1.0, 0.0], 0.4),
|
||||
([0.0, 1.0, 1.0], 0.6),
|
||||
([0.0, 0.0, 1.0], 0.2),
|
||||
([1.0, 0.0, 1.0], 0.8),
|
||||
// Half as bright, half the gray; orange is halfway from Reds to Yellows.
|
||||
([0.5, 0.0, 0.0], 0.2),
|
||||
([1.0, 0.5, 0.0], 0.5),
|
||||
// Neutral pixels keep their value.
|
||||
([0.3, 0.3, 0.3], 0.3),
|
||||
([4.0, 4.0, 4.0], 4.0),
|
||||
// An overbright red: its smallest channel plus its chroma at Reds' 40 %.
|
||||
([1.5, 0.5, 0.5], 0.9),
|
||||
];
|
||||
for (c, want) in hues {
|
||||
let o = bw(&[], c);
|
||||
assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
|
||||
}
|
||||
assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
|
||||
assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
|
||||
assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
|
||||
}
|
||||
|
||||
/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
|
||||
/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
|
||||
/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
|
||||
/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
|
||||
#[test]
|
||||
fn black_white_keeps_faint_colour_noise_faint() {
|
||||
// A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
|
||||
for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// A dark ramp up to a quarter gray, in whole steps...
|
||||
let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
|
||||
// ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
|
||||
let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
|
||||
clean.set(x, y, [v, v, v, 1.0]);
|
||||
noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
|
||||
}
|
||||
}
|
||||
let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
|
||||
let (a, b) = (bw(clean), bw(noisy));
|
||||
let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
|
||||
assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))];
|
||||
assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5);
|
||||
let mut no_white = base.to_vec();
|
||||
no_white.push(("clipToOutputWhite", Value::Enum(0)));
|
||||
assert!(lv(&no_white, 0.9) > 0.9);
|
||||
assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5);
|
||||
// The hidden switch of older projects turns both off.
|
||||
let mut legacy = base.to_vec();
|
||||
legacy.push(("noClip", Value::Bool(true)));
|
||||
assert!(lv(&legacy, 0.9) > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_individual_channels_and_bypass() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g);
|
||||
assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5);
|
||||
// Master values are ignored in Individual Channels mode.
|
||||
assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5));
|
||||
// Bypass Linear Light Conversion: +1 stop doubles the raw value.
|
||||
let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!((raw[0] - 0.5).abs() < 1e-5);
|
||||
// Negative results are mirrored, not clipped.
|
||||
let neg = px(
|
||||
"ec.color.exposure",
|
||||
&[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))],
|
||||
[0.25, 0.25, 0.25, 1.0],
|
||||
);
|
||||
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_to_color_change_modes_tolerances_and_matte() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
let green = [0.1, 0.8, 0.1, 1.0];
|
||||
let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))];
|
||||
// Hue only: red turns blue with its own lightness / saturation; green is untouched.
|
||||
let o = px("ec.color.changetocolor", &base, red);
|
||||
assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}");
|
||||
assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5));
|
||||
// The matte is white where matched, black elsewhere.
|
||||
let mut m = base.to_vec();
|
||||
m.push(("viewCorrectionMatte", Value::Bool(true)));
|
||||
assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5));
|
||||
assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// A lightness tolerance of 0 rejects a lighter red.
|
||||
let mut strict = m.clone();
|
||||
strict.push(("toleranceGroup/lightness", num(0.0)));
|
||||
strict.push(("softness", num(0.0)));
|
||||
assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// Hue, Lightness & Saturation, set: a matched pixel becomes the To colour.
|
||||
let mut all = base.to_vec();
|
||||
all.push(("change", Value::Enum(3)));
|
||||
assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_color_tolerance_softness_and_match_mode() {
|
||||
let green = [0.1, 0.8, 0.1, 1.0];
|
||||
let lc = |vals: &[(&str, Value)]| {
|
||||
let mut v = vec![("amount", num(100.0))];
|
||||
v.extend_from_slice(vals);
|
||||
px("ec.color.leavecolor", &v, green)
|
||||
};
|
||||
let g = luminance(0.1, 0.8, 0.1);
|
||||
assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5));
|
||||
assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6));
|
||||
// Softness keeps part of the colour.
|
||||
let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]);
|
||||
assert!(s[1] > g + 0.05 && s[1] < 0.8);
|
||||
// Using Hue: a dark red still matches pure red.
|
||||
let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]);
|
||||
assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_filter_presets_and_custom() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g);
|
||||
assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}");
|
||||
let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g);
|
||||
assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]);
|
||||
// The Color swatch is ignored by the presets.
|
||||
let warm = |c| px("ec.color.photofilter", &[("color", c)], g);
|
||||
assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_preserve_luminosity_and_black_stretch() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g);
|
||||
assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]);
|
||||
let dark = [0.1, 0.1, 0.1, 1.0];
|
||||
assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
|
||||
assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_channel_controls_act_on_their_channel() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
|
||||
let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
|
||||
assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
|
||||
let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
|
||||
let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
|
||||
// Per-channel clipping follows its own popups.
|
||||
let hot = [0.9, 0.9, 0.9, 1.0];
|
||||
let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
|
||||
let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
|
||||
assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_ranges_adjust_their_hues_only() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
let blue = [0.1, 0.1, 0.8, 1.0];
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let v = [("redsSaturation", num(-100.0))];
|
||||
let r = px("ec.color.huesaturation", &v, red);
|
||||
assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
|
||||
assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
|
||||
assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
|
||||
// Blues hue +120°: blue becomes red.
|
||||
let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
|
||||
assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
|
||||
// Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
|
||||
let orange_ish = {
|
||||
let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
|
||||
[r, g, b, 1.0]
|
||||
};
|
||||
let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
|
||||
let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
|
||||
assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
|
||||
// Moving the range changes which hues it takes.
|
||||
let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
|
||||
assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
|
||||
let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
|
||||
assert!(!huesat_ranges_identity(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_mask_all_masks_feather_and_invert() {
|
||||
let sq = |x0: f64, x1: f64| crate::MaskShape {
|
||||
name: String::new(),
|
||||
points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]],
|
||||
closed: true,
|
||||
inverted: false,
|
||||
};
|
||||
let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
|
||||
let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
|
||||
let one = run(&[("fillMask", num(1.0))]);
|
||||
assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
let all = run(&[("allMasks", Value::Bool(true))]);
|
||||
assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
|
||||
assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
|
||||
let v = soft.get(6, 5)[1];
|
||||
assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
|
||||
// Without masks, Fill fills the layer as before.
|
||||
let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_palettes_modify_selection_masking_and_add_phase() {
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
// Ramp Grayscale: intensity 0.5 maps to mid grey.
|
||||
let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
|
||||
assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
|
||||
// Custom palette: two stops, black at 0 and blue at 0.5.
|
||||
let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
|
||||
let c = px("ec.color.colorama", &custom, grey);
|
||||
assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
// Modify Lightness keeps the hue of the pixel (red stays red).
|
||||
let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
|
||||
assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
|
||||
// Pixel Selection: only colours near Matching Color change.
|
||||
let sel = [
|
||||
("pixelSelection/matchingMode", Value::Enum(1)),
|
||||
("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
|
||||
("pixelSelection/matchingTolerance", num(10.0)),
|
||||
];
|
||||
assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
|
||||
assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
|
||||
// Composite Over Layer off: unselected pixels vanish.
|
||||
let mut off = sel.to_vec();
|
||||
off.push(("masking/compositeOverLayer", Value::Bool(false)));
|
||||
assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
|
||||
// Modify Alpha takes the palette alpha.
|
||||
let alpha = [
|
||||
("outputCycle/usePresetPalette", Value::Enum(8)),
|
||||
("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
|
||||
("modify/modifyAlpha", Value::Bool(true)),
|
||||
];
|
||||
assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
|
||||
// Palette parsing and evaluation.
|
||||
let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
|
||||
assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
|
||||
assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,508 @@
|
||||
use super::*;
|
||||
use crate::{EffectEnv, run_fx};
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn px(id: &str, vals: &[(&str, Value)], c: [f32; 4]) -> [f32; 4] {
|
||||
run_fx(id, vals, Image::filled(2, 2, c), 0.0, EffectEnv::default()).img.get(0, 0)
|
||||
}
|
||||
fn close(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
/// Projects saved before Use Legacy / the Photo Filter menu existed keep rendering as before.
|
||||
#[test]
|
||||
fn old_saves_get_legacy_values_for_added_params() {
|
||||
use effectcraft_project::build::Ids;
|
||||
let mut next = 1;
|
||||
for (id, param, want) in
|
||||
[("ec.color.brightnesscontrast", "useLegacy", Value::Bool(true)), ("ec.color.photofilter", "filter", Value::Enum(PHOTO_FILTER_CUSTOM))]
|
||||
{
|
||||
let spec = crate::find(id).unwrap();
|
||||
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
|
||||
g.children.retain(|c| c.match_id() != param);
|
||||
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
|
||||
assert_eq!(g.get(param).unwrap().value, want, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working
|
||||
/// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values.
|
||||
#[test]
|
||||
fn tint_defaults_match_after_effects_greys() {
|
||||
for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
|
||||
let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]);
|
||||
let want = want / 255.0;
|
||||
assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_reads_get_phase_from() {
|
||||
// Same intensity, different red: Intensity gives one colour, Red gives two.
|
||||
let a = [0.6, 0.2, 0.2, 1.0];
|
||||
let b = [0.2, 0.2, 0.6, 1.0];
|
||||
let red = |c| px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(1))], c);
|
||||
assert!(!close(red(a), red(b), 1e-3));
|
||||
// Zero maps everything to the start of the cycle (red).
|
||||
assert!(close(px("ec.color.colorama", &[("inputPhase/mode", Value::Enum(9))], b), [1.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// Interpolate Palette off posterizes: two nearby inputs land on the same colour.
|
||||
let off = |v: f32| px("ec.color.colorama", &[("outputCycle/interpolatePalette", Value::Bool(false))], [v, v, v, 1.0]);
|
||||
assert_eq!(off(0.20), off(0.22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_contrast_modern_keeps_black_and_white_and_legacy_is_linear() {
|
||||
let bc = |vals: &[(&str, Value)], v: f32| px("ec.color.brightnesscontrast", vals, [v, v, v, 1.0])[0];
|
||||
let up = [("brightness", num(50.0)), ("contrast", num(40.0))];
|
||||
assert_eq!(bc(&up, 0.0), 0.0);
|
||||
assert!((bc(&up, 1.0) - 1.0).abs() < 1e-6);
|
||||
assert!(bc(&up, 0.4) > 0.4);
|
||||
let legacy = [("brightness", num(50.0)), ("useLegacy", Value::Bool(true))];
|
||||
assert!((bc(&legacy, 0.2) - 0.7).abs() < 1e-5);
|
||||
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
|
||||
#[test]
|
||||
fn brightness_contrast_modern_matches_ae_ramp_samples() {
|
||||
let u8 = |v: f32| (v * 255.0).round() as i32;
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])[0])
|
||||
};
|
||||
// (brightness, contrast, input, AE 8-bit)
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(0.0, 100.0, 64, 40),
|
||||
(0.0, 100.0, 128, 129),
|
||||
(0.0, 100.0, 192, 217),
|
||||
(0.0, 50.0, 64, 53),
|
||||
(0.0, 50.0, 192, 205),
|
||||
(0.0, -50.0, 64, 77),
|
||||
(100.0, 0.0, 128, 209),
|
||||
(50.0, 0.0, 128, 171),
|
||||
(106.0, -18.0, 128, 214),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows
|
||||
/// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46);
|
||||
/// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64),
|
||||
/// (153, 51, 51), (204, 153, 128).
|
||||
#[test]
|
||||
fn tritone_matches_after_effects() {
|
||||
let colours =
|
||||
[("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0]))];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0);
|
||||
let mut red = colours.to_vec();
|
||||
red.push(("blend", num(25.0)));
|
||||
let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] {
|
||||
let v = level / 255.0;
|
||||
let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]);
|
||||
assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow
|
||||
/// and white solids at the default sliders (case 825).
|
||||
#[test]
|
||||
fn black_white_tint_matches_after_effects() {
|
||||
let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))];
|
||||
let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5);
|
||||
let mut reds = tint.to_vec();
|
||||
reds.push(("reds", num(10.0)));
|
||||
let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]);
|
||||
assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]);
|
||||
assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0));
|
||||
// Hostile grays stay finite.
|
||||
for g in [f32::NAN, -1.0, 0.0, 4.0] {
|
||||
assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast
|
||||
/// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit)
|
||||
#[test]
|
||||
fn negative_brightness_and_contrast_invert_the_positive_curves() {
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
let v = x as f32 / 255.0;
|
||||
(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32
|
||||
};
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(-40.0, 0.0, 64, 50),
|
||||
(-40.0, 0.0, 128, 99),
|
||||
(-40.0, 0.0, 191, 155),
|
||||
(-40.0, 0.0, 234, 207),
|
||||
(0.0, -50.0, 21, 28),
|
||||
(0.0, -50.0, 64, 76),
|
||||
(0.0, -50.0, 191, 179),
|
||||
(0.0, -50.0, 234, 227),
|
||||
(40.0, 0.0, 128, 162),
|
||||
(0.0, 50.0, 64, 52),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1463: Black & White's sliders set the gray of each pure hue and blend between
|
||||
/// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright
|
||||
/// colour stays finite (it used to divide by zero).
|
||||
#[test]
|
||||
fn black_white_sliders_set_the_gray_of_each_hue() {
|
||||
let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]);
|
||||
let gray = |v: f32| [v, v, v, 1.0];
|
||||
// The defaults: Reds 40, Yellows 60, Greens 40, Cyans 60, Blues 20, Magentas 80 (%).
|
||||
let hues = [
|
||||
([1.0, 0.0, 0.0], 0.4),
|
||||
([1.0, 1.0, 0.0], 0.6),
|
||||
([0.0, 1.0, 0.0], 0.4),
|
||||
([0.0, 1.0, 1.0], 0.6),
|
||||
([0.0, 0.0, 1.0], 0.2),
|
||||
([1.0, 0.0, 1.0], 0.8),
|
||||
// Half as bright, half the gray; orange is halfway from Reds to Yellows.
|
||||
([0.5, 0.0, 0.0], 0.2),
|
||||
([1.0, 0.5, 0.0], 0.5),
|
||||
// Neutral pixels keep their value.
|
||||
([0.3, 0.3, 0.3], 0.3),
|
||||
([4.0, 4.0, 4.0], 4.0),
|
||||
// An overbright red: its smallest channel plus its chroma at Reds' 40 %.
|
||||
([1.5, 0.5, 0.5], 0.9),
|
||||
];
|
||||
for (c, want) in hues {
|
||||
let o = bw(&[], c);
|
||||
assert!(close(o, gray(want), 1e-5), "{c:?}: {o:?}, want {want}");
|
||||
}
|
||||
assert!(close(bw(&[("reds", num(120.0))], [1.0, 0.0, 0.0]), gray(1.2), 1e-5));
|
||||
assert!(close(bw(&[("reds", num(-200.0))], [1.0, 0.0, 0.0]), gray(0.0), 1e-5));
|
||||
assert!(close(bw(&[("blues", num(300.0))], [0.1, 0.1, 0.3]), gray(0.7), 1e-5));
|
||||
}
|
||||
|
||||
/// #1463: faint colour noise stays faint. The shadows of compressed footage carry a code or
|
||||
/// two of colour noise, which Black & White turned into bright and black blocks (the HSL
|
||||
/// saturation it scaled is near 1 for any tinted near-black pixel). At 8, 16 and 32 bpc the
|
||||
/// gray of a dark ramp with ±2 steps of colour noise stays within 2 steps of the clean ramp's.
|
||||
#[test]
|
||||
fn black_white_keeps_faint_colour_noise_faint() {
|
||||
// A step of 8 bpc, of 16 bpc (After Effects' 0..32768), and a small 32-bpc float one.
|
||||
for step in [1.0 / 255.0, 1.0 / 32768.0, 1e-3_f32] {
|
||||
let (w, h) = (64u32, 4u32);
|
||||
let (mut clean, mut noisy) = (Image::new(w, h), Image::new(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// A dark ramp up to a quarter gray, in whole steps...
|
||||
let v = (x as f32 / w as f32 * 0.25 / step).round() * step;
|
||||
// ...and up to ±2 steps of noise per channel, mixed differently in each pixel.
|
||||
let n = |k: u32| (((x * 7 + y * 13 + k * 2) % 5) as f32 - 2.0) * step;
|
||||
clean.set(x, y, [v, v, v, 1.0]);
|
||||
noisy.set(x, y, [(v + n(0)).max(0.0), (v + n(1)).max(0.0), (v + n(2)).max(0.0), 1.0]);
|
||||
}
|
||||
}
|
||||
let bw = |img| run_fx("ec.color.blackwhite", &[], img, 0.0, EffectEnv::default()).img;
|
||||
let (a, b) = (bw(clean), bw(noisy));
|
||||
let worst = a.data.iter().zip(&b.data).map(|(p, q)| (p[0] - q[0]).abs()).fold(0.0, f32::max);
|
||||
assert!(worst <= 2.0 * step + 1e-6, "step {step}: the noise moved the gray by {} steps", worst / step);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
let base = [("inBlack", num(0.2)), ("inWhite", num(0.6)), ("outBlack", num(0.1)), ("outWhite", num(0.9))];
|
||||
assert!((lv(&base, 0.9) - 0.9).abs() < 1e-5);
|
||||
let mut no_white = base.to_vec();
|
||||
no_white.push(("clipToOutputWhite", Value::Enum(0)));
|
||||
assert!(lv(&no_white, 0.9) > 0.9);
|
||||
assert!((lv(&no_white, 0.0) - 0.1).abs() < 1e-5);
|
||||
// The hidden switch of older projects turns both off.
|
||||
let mut legacy = base.to_vec();
|
||||
legacy.push(("noClip", Value::Bool(true)));
|
||||
assert!(lv(&legacy, 0.9) > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_individual_channels_and_bypass() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let one = px("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", num(1.0))], g);
|
||||
assert!(one[0] > 0.6 && (one[1] - 0.5).abs() < 1e-5 && (one[2] - 0.5).abs() < 1e-5);
|
||||
// Master values are ignored in Individual Channels mode.
|
||||
assert!(close(px("ec.color.exposure", &[("channels", Value::Enum(1)), ("master/exposure", num(2.0))], g), g, 1e-5));
|
||||
// Bypass Linear Light Conversion: +1 stop doubles the raw value.
|
||||
let raw = px("ec.color.exposure", &[("master/exposure", num(1.0)), ("bypassLinearLight", Value::Bool(true))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!((raw[0] - 0.5).abs() < 1e-5);
|
||||
// Negative results are mirrored, not clipped.
|
||||
let neg =
|
||||
px("ec.color.exposure", &[("master/offset", num(-0.5)), ("bypassLinearLight", Value::Bool(true)), ("master/gamma", num(2.0))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}");
|
||||
}
|
||||
|
||||
/// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma
|
||||
/// of 0.05, valid before, as in Photoshop) survives loading and still renders.
|
||||
#[test]
|
||||
fn exposure_has_after_effects_ranges_and_keeps_old_values() {
|
||||
use effectcraft_project::build::Ids;
|
||||
let spec = crate::find("ec.color.exposure").unwrap();
|
||||
let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)];
|
||||
let sliders: Vec<_> = spec
|
||||
.params
|
||||
.iter()
|
||||
.filter_map(|p| match p.ui {
|
||||
ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sliders.len(), 12);
|
||||
for (i, (id, r)) in sliders.iter().enumerate() {
|
||||
assert_eq!(*r, want[i % 3], "{id}");
|
||||
}
|
||||
let mut next = 1;
|
||||
let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
gamma.value = num(0.05);
|
||||
gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2);
|
||||
crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]);
|
||||
let gamma = g.prop_mut("master/gamma").unwrap();
|
||||
assert_eq!(gamma.value, num(0.05));
|
||||
assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1));
|
||||
let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}");
|
||||
}
|
||||
|
||||
/// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space,
|
||||
/// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power.
|
||||
/// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets.
|
||||
#[test]
|
||||
fn exposure_matches_after_effects_gamma_2_2() {
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let code = |o: [f32; 4], i: usize| o[i] * 255.0;
|
||||
// Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture).
|
||||
for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] {
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(offset))], red);
|
||||
for i in [1, 2] {
|
||||
assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i));
|
||||
}
|
||||
assert!(o[0] >= 1.0, "{o:?}");
|
||||
}
|
||||
// Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54.
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red);
|
||||
assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red);
|
||||
assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}");
|
||||
// Default settings leave 32-bpc values alone, negative and over-range ones included.
|
||||
let hdr = [-0.25, 0.5, 2.0, 1.0];
|
||||
assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5));
|
||||
// A negative Offset pushes black below zero, mirrored, rather than clipping or NaN.
|
||||
let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_to_color_change_modes_tolerances_and_matte() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
let green = [0.1, 0.8, 0.1, 1.0];
|
||||
let base = [("from", col(0.8, 0.1, 0.1)), ("to", col(0.1, 0.1, 0.8))];
|
||||
// Hue only: red turns blue with its own lightness / saturation; green is untouched.
|
||||
let o = px("ec.color.changetocolor", &base, red);
|
||||
assert!(o[2] > 0.7 && o[0] < 0.2, "{o:?}");
|
||||
assert!(close(px("ec.color.changetocolor", &base, green), green, 1e-5));
|
||||
// The matte is white where matched, black elsewhere.
|
||||
let mut m = base.to_vec();
|
||||
m.push(("viewCorrectionMatte", Value::Bool(true)));
|
||||
assert!(close(px("ec.color.changetocolor", &m, red), [1.0, 1.0, 1.0, 1.0], 1e-5));
|
||||
assert!(close(px("ec.color.changetocolor", &m, green), [0.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// A lightness tolerance of 0 rejects a lighter red.
|
||||
let mut strict = m.clone();
|
||||
strict.push(("toleranceGroup/lightness", num(0.0)));
|
||||
strict.push(("softness", num(0.0)));
|
||||
assert!(close(px("ec.color.changetocolor", &strict, [1.0, 0.5, 0.5, 1.0]), [0.0, 0.0, 0.0, 1.0], 1e-5));
|
||||
// Hue, Lightness & Saturation, set: a matched pixel becomes the To colour.
|
||||
let mut all = base.to_vec();
|
||||
all.push(("change", Value::Enum(3)));
|
||||
assert!(close(px("ec.color.changetocolor", &all, red), [0.1, 0.1, 0.8, 1.0], 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_color_tolerance_softness_and_match_mode() {
|
||||
let green = [0.1, 0.8, 0.1, 1.0];
|
||||
let lc = |vals: &[(&str, Value)]| {
|
||||
let mut v = vec![("amount", num(100.0))];
|
||||
v.extend_from_slice(vals);
|
||||
px("ec.color.leavecolor", &v, green)
|
||||
};
|
||||
let g = luminance(0.1, 0.8, 0.1);
|
||||
assert!(close(lc(&[]), [g, g, g, 1.0], 1e-5));
|
||||
assert!(close(lc(&[("tolerance", num(100.0))]), green, 1e-6));
|
||||
// Softness keeps part of the colour.
|
||||
let s = lc(&[("tolerance", num(10.0)), ("edgeSoftness", num(100.0))]);
|
||||
assert!(s[1] > g + 0.05 && s[1] < 0.8);
|
||||
// Using Hue: a dark red still matches pure red.
|
||||
let dark = px("ec.color.leavecolor", &[("amount", num(100.0)), ("tolerance", num(5.0)), ("matchColors", Value::Enum(1))], [0.4, 0.0, 0.0, 1.0]);
|
||||
assert!(close(dark, [0.4, 0.0, 0.0, 1.0], 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_filter_presets_and_custom() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let cool = px("ec.color.photofilter", &[("filter", Value::Enum(3)), ("preserveLuminosity", Value::Bool(false))], g);
|
||||
assert!(cool[2] > cool[0], "a cooling filter is blue: {cool:?}");
|
||||
let custom = |c| px("ec.color.photofilter", &[("filter", Value::Enum(PHOTO_FILTER_CUSTOM)), ("color", c)], g);
|
||||
assert!(custom(col(0.0, 1.0, 0.0))[1] > custom(col(1.0, 0.0, 0.0))[1]);
|
||||
// The Color swatch is ignored by the presets.
|
||||
let warm = |c| px("ec.color.photofilter", &[("color", c)], g);
|
||||
assert_eq!(warm(col(0.0, 1.0, 0.0)), warm(col(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_balance_preserve_luminosity_and_black_stretch() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
let o = px("ec.color.colorbalance", &[("midRed", num(60.0)), ("preserveLuminosity", Value::Bool(true))], g);
|
||||
assert!((luminance(o[0], o[1], o[2]) - 0.5).abs() < 1e-4 && o[0] > o[2]);
|
||||
let dark = [0.1, 0.1, 0.1, 1.0];
|
||||
assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
|
||||
assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_channel_controls_act_on_their_channel() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
|
||||
let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
|
||||
assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
|
||||
let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
|
||||
let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
|
||||
// Per-channel clipping follows its own popups.
|
||||
let hot = [0.9, 0.9, 0.9, 1.0];
|
||||
let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
|
||||
let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
|
||||
assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_ranges_adjust_their_hues_only() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
let blue = [0.1, 0.1, 0.8, 1.0];
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let v = [("redsSaturation", num(-100.0))];
|
||||
let r = px("ec.color.huesaturation", &v, red);
|
||||
assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
|
||||
assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
|
||||
assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
|
||||
// Blues hue +120°: blue becomes red.
|
||||
let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
|
||||
assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
|
||||
// Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
|
||||
let orange_ish = {
|
||||
let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
|
||||
[r, g, b, 1.0]
|
||||
};
|
||||
let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
|
||||
let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
|
||||
assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
|
||||
// Moving the range changes which hues it takes.
|
||||
let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
|
||||
assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
|
||||
let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
|
||||
assert!(!huesat_ranges_identity(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_mask_all_masks_feather_and_invert() {
|
||||
let sq =
|
||||
|x0: f64, x1: f64| crate::MaskShape { name: String::new(), points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], closed: true, inverted: false };
|
||||
let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
|
||||
let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
|
||||
let one = run(&[("fillMask", num(1.0))]);
|
||||
assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
let all = run(&[("allMasks", Value::Bool(true))]);
|
||||
assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
|
||||
assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
|
||||
let v = soft.get(6, 5)[1];
|
||||
assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
|
||||
// Without masks, Fill fills the layer as before.
|
||||
let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp
|
||||
/// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the
|
||||
/// fill colour at half the layer's alpha, nothing of the ramp left.
|
||||
#[test]
|
||||
fn fill_opacity_is_the_layers_opacity_as_in_after_effects() {
|
||||
let mut img = Image::new(16, 4);
|
||||
for x in 0..16 {
|
||||
let v = x as f32 / 15.0;
|
||||
for y in 0..4 {
|
||||
img.set(x, y, [v, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))];
|
||||
let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img;
|
||||
for x in [0, 7, 15] {
|
||||
let p = out.get(x, 2);
|
||||
let ae = [0.0, 0.251, 0.502, 0.502];
|
||||
assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}");
|
||||
}
|
||||
// A half-transparent pixel ends at a quarter alpha, still the fill colour.
|
||||
let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_palettes_modify_selection_masking_and_add_phase() {
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
// Ramp Grayscale: intensity 0.5 maps to mid grey.
|
||||
let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
|
||||
assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
|
||||
// Custom palette: two stops, black at 0 and blue at 0.5.
|
||||
let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
|
||||
let c = px("ec.color.colorama", &custom, grey);
|
||||
assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
// Modify Lightness keeps the hue of the pixel (red stays red).
|
||||
let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
|
||||
assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
|
||||
// Pixel Selection: only colours near Matching Color change.
|
||||
let sel = [
|
||||
("pixelSelection/matchingMode", Value::Enum(1)),
|
||||
("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
|
||||
("pixelSelection/matchingTolerance", num(10.0)),
|
||||
];
|
||||
assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
|
||||
assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
|
||||
// Composite Over Layer off: unselected pixels vanish.
|
||||
let mut off = sel.to_vec();
|
||||
off.push(("masking/compositeOverLayer", Value::Bool(false)));
|
||||
assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
|
||||
// Modify Alpha takes the palette alpha.
|
||||
let alpha = [
|
||||
("outputCycle/usePresetPalette", Value::Enum(8)),
|
||||
("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
|
||||
("modify/modifyAlpha", Value::Bool(true)),
|
||||
];
|
||||
assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
|
||||
// Palette parsing and evaluation.
|
||||
let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
|
||||
assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
|
||||
assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it
|
||||
//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads.
|
||||
|
||||
use crate::channel3d::{selection_hashes, selection_names};
|
||||
|
||||
/// How a pick changes the selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PickMode {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a
|
||||
/// backslash, which the selection parser would otherwise split on or read as syntax.
|
||||
fn quote(name: &str) -> String {
|
||||
if !name.contains([',', '"', '\'', '\\']) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut out = String::from("\"");
|
||||
for c in name.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it
|
||||
/// reads back as exactly that ID, else `<hex>`. That covers unnamed IDs, names with `*` (which
|
||||
/// would select by wildcard), names that look like `<hex>`, names with surrounding spaces (the
|
||||
/// parser trims them) and names shared by several IDs.
|
||||
pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String {
|
||||
match name {
|
||||
Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n),
|
||||
_ => format!("<{hash:08x}>"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text
|
||||
/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just
|
||||
/// that ID, Add when it already selects it, Remove when no name or `<hex>` token selects it (a
|
||||
/// wildcard that matches it stays). Other tokens (wildcards included) keep their text,
|
||||
/// re-quoted where needed, separated by `, `.
|
||||
pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String {
|
||||
let tokens: Vec<String> = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect();
|
||||
let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash);
|
||||
let kept: Vec<String> = match mode {
|
||||
PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(),
|
||||
PickMode::Replace => return token.to_string(),
|
||||
PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(),
|
||||
PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(),
|
||||
PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(),
|
||||
PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(),
|
||||
};
|
||||
kept.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_raster::channels3d::crypto_hash;
|
||||
|
||||
fn manifest() -> Vec<(String, u32)> {
|
||||
["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"]
|
||||
.iter()
|
||||
.map(|n| (n.to_string(), crypto_hash(n)))
|
||||
.chain([("twin".to_string(), 7)])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every name's token reads back as exactly its ID, through the effect's own parser.
|
||||
#[test]
|
||||
fn tokens_round_trip_through_selection_hashes() {
|
||||
let m = manifest();
|
||||
for (n, h) in &m {
|
||||
let t = token_for(Some(n), *h, Some(&m));
|
||||
assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}");
|
||||
}
|
||||
assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny");
|
||||
assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#);
|
||||
assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#);
|
||||
// Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash.
|
||||
assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*")));
|
||||
assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>")));
|
||||
assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>");
|
||||
assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad ")));
|
||||
assert_eq!(token_for(None, 0xdead_beef, None), "<deadbeef>");
|
||||
assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_remove_keep_other_tokens_and_skip_duplicates() {
|
||||
let m = manifest();
|
||||
let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy"));
|
||||
let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m));
|
||||
let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m));
|
||||
assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny");
|
||||
assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged");
|
||||
assert_eq!(edit("", "bunny", PickMode::Add), "bunny");
|
||||
assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#);
|
||||
// Already selected: by name, by hash, by a wildcard. The text stays as typed.
|
||||
assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor");
|
||||
assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>"));
|
||||
assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*");
|
||||
// Other tokens keep their meaning: quoted where needed, wildcards stay wildcards.
|
||||
assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#);
|
||||
assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#);
|
||||
assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x");
|
||||
assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), "");
|
||||
// Not selected, or only by a wildcard: unchanged.
|
||||
assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b");
|
||||
assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x");
|
||||
for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] {
|
||||
let before = selection_hashes(sel, Some(&m));
|
||||
let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m));
|
||||
assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
-36
@@ -164,7 +164,10 @@ fn wave_warp(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let kind = ctx.params.e("waveType");
|
||||
let pin = ctx.params.e("pinning");
|
||||
let seed = ctx.seed ^ 0x3a7e;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(h.abs().ceil() as u32 + 1);
|
||||
}
|
||||
@@ -272,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
|
||||
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
let anchor = b.to_px(pr.v2("anchor"));
|
||||
let pos = b.to_px(pr.v2("position"));
|
||||
/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer),
|
||||
/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)`
|
||||
/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation.
|
||||
pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 {
|
||||
let anchor = to_px(pr.v2("anchor"));
|
||||
let pos = to_px(pr.v2("position"));
|
||||
let sh = pr.f("scaleHeight");
|
||||
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
|
||||
let axis = pr.f("skewAxis");
|
||||
let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan();
|
||||
let k = if k.is_finite() { k } else { 0.0 };
|
||||
let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis);
|
||||
Mat3::translate(vec2(pos.0, pos.1))
|
||||
* skew
|
||||
* Mat3::rotate_deg(pr.f("rotation"))
|
||||
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
transform_matrix_px(pr, |p| b.to_px(p))
|
||||
}
|
||||
|
||||
/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by
|
||||
/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds
|
||||
/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a
|
||||
/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs
|
||||
/// a bounded allocation.
|
||||
pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] {
|
||||
const SIDE: f64 = 16_384.0;
|
||||
const AREA: f64 = 16_777_216.0;
|
||||
let (wf, hf) = (w as f64, h as f64);
|
||||
let mut m = [0.0f64; 4];
|
||||
for mat in ms {
|
||||
for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] {
|
||||
let q = mat.apply(vec2(x, y));
|
||||
if q.x.is_finite() && q.y.is_finite() {
|
||||
m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)];
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = m.map(|v| v.ceil().max(0.0));
|
||||
let fit = |len: f64, a: f64, b: f64| -> f64 {
|
||||
let room = (SIDE - len).max(0.0);
|
||||
if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 }
|
||||
};
|
||||
let kx = fit(wf, m[0], m[2]);
|
||||
let ky = fit(hf, m[1], m[3]);
|
||||
let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k);
|
||||
let k = if area(1.0) > AREA && area(1.0) > wf * hf {
|
||||
// Shrink both margins by the same factor until the area fits.
|
||||
let (mut lo, mut hi) = (0.0f64, 1.0f64);
|
||||
for _ in 0..40 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if area(mid) > AREA { hi = mid } else { lo = mid }
|
||||
}
|
||||
lo
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
[m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32)
|
||||
}
|
||||
|
||||
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
let uniform = ctx.params.b("uniform");
|
||||
let sh = ctx.params.f("scaleHeight");
|
||||
let sw = if uniform { sh } else { ctx.params.f("scaleWidth") };
|
||||
let skew = ctx.params.f("skew");
|
||||
let skew_axis = ctx.params.f("skewAxis");
|
||||
let rot = ctx.params.f("rotation");
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
|
||||
// Motion blur: the transform at Samples instants across the shutter, averaged (the
|
||||
@@ -301,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
&& let Some(host) = ctx.env.host
|
||||
{
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut count = 0.0f32;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let Some(pr) = host.params_at(t) else { continue };
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(&pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
count += 1.0;
|
||||
}
|
||||
if count > 0.0 {
|
||||
let samples: Vec<crate::Params> = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect();
|
||||
if !samples.is_empty() {
|
||||
if !ctx.adjustment {
|
||||
let ms: Vec<Mat3> = samples.iter().map(|pr| transform_matrix(pr, &b)).collect();
|
||||
b.pad_sides(transform_grow(&ms, b.img.width, b.img.height));
|
||||
}
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
for pr in &samples {
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
}
|
||||
let count = samples.len() as f32;
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
|
||||
b.img = acc;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
let m = Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(rot)
|
||||
* Mat3::skew_deg(skew, skew_axis)
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1));
|
||||
if !ctx.adjustment {
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
b.pad_sides(transform_grow(&[m], b.img.width, b.img.height));
|
||||
}
|
||||
let m = transform_matrix(ctx.params, &b);
|
||||
let mut out = Image::new(b.img.width, b.img.height);
|
||||
effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() });
|
||||
b.img = out;
|
||||
@@ -556,8 +604,159 @@ mod tests {
|
||||
let (a, b) = (o.img.get(pad, y + pad), im.get(0, y));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}");
|
||||
}
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im);
|
||||
let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone());
|
||||
assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b)));
|
||||
// #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row,
|
||||
// Bottom Edge (6) the last, and the rest of the layer still moves.
|
||||
for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] {
|
||||
let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)];
|
||||
let o = run("ec.distort.wavewarp", &set, im.clone());
|
||||
let pad = ((o.img.width - im.width) / 2) as i64;
|
||||
for x in 0..40 {
|
||||
let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row));
|
||||
assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
let mid = 15;
|
||||
assert!((0..40).any(|x| differs(o.img.get(x + pad, mid + pad), im.get(x, mid))), "pin {edge_row}: the middle of the layer must still distort");
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080
|
||||
/// comp with Transform. The skew applies after the rotation, and the result is drawn outside
|
||||
/// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from
|
||||
/// an edge; true = the solid covers it), half where the old order and clipping differed.
|
||||
#[test]
|
||||
fn transform_matches_after_effects() {
|
||||
let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]);
|
||||
let p2 = |x: f64, y: f64| Value::Vec2([x, y]);
|
||||
let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [
|
||||
(
|
||||
"723",
|
||||
vec![
|
||||
("anchor", p2(400.0, 200.0)),
|
||||
("position", p2(500.0, 300.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleHeight", num(80.0)),
|
||||
("scaleWidth", num(120.0)),
|
||||
("skew", num(10.0)),
|
||||
("skewAxis", num(20.0)),
|
||||
("rotation", num(30.0)),
|
||||
],
|
||||
&[
|
||||
(1465, 954, true),
|
||||
(1127, 821, true),
|
||||
(1378, 846, true),
|
||||
(1416, 878, true),
|
||||
(1486, 850, true),
|
||||
(492, 467, true),
|
||||
(706, 239, true),
|
||||
(1402, 1021, true),
|
||||
(518, 440, true),
|
||||
(1218, 882, true),
|
||||
(454, 448, true),
|
||||
(1473, 920, true),
|
||||
(1048, 654, true),
|
||||
(1050, 486, true),
|
||||
(1060, 736, true),
|
||||
(932, 804, true),
|
||||
(1074, 562, true),
|
||||
(653, 591, true),
|
||||
(1353, 466, false),
|
||||
(88, 1021, false),
|
||||
(1855, 886, false),
|
||||
(1796, 401, false),
|
||||
(497, 913, false),
|
||||
(1445, 515, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"819",
|
||||
vec![("skew", num(15.0)), ("skewAxis", num(60.0))],
|
||||
&[
|
||||
(465, 367, true),
|
||||
(554, 793, false),
|
||||
(481, 529, true),
|
||||
(1451, 599, true),
|
||||
(428, 575, true),
|
||||
(432, 600, true),
|
||||
(463, 444, true),
|
||||
(759, 803, false),
|
||||
(445, 684, true),
|
||||
(1411, 757, true),
|
||||
(915, 784, false),
|
||||
(480, 737, true),
|
||||
(758, 620, true),
|
||||
(975, 705, true),
|
||||
(1289, 376, true),
|
||||
(575, 420, true),
|
||||
(1015, 507, true),
|
||||
(1377, 448, true),
|
||||
(1679, 171, false),
|
||||
(194, 435, false),
|
||||
(413, 159, false),
|
||||
(327, 741, false),
|
||||
(1824, 138, false),
|
||||
(1093, 29, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"820",
|
||||
vec![
|
||||
("skew", num(15.0)),
|
||||
("skewAxis", num(60.0)),
|
||||
("rotation", num(-25.0)),
|
||||
("uniform", Value::Bool(false)),
|
||||
("scaleWidth", num(130.0)),
|
||||
("scaleHeight", num(70.0)),
|
||||
("anchor", p2(100.0, 50.0)),
|
||||
],
|
||||
&[
|
||||
(1674, 566, true),
|
||||
(1425, 317, false),
|
||||
(1788, 278, true),
|
||||
(1530, 390, true),
|
||||
(1531, 652, true),
|
||||
(1589, 545, true),
|
||||
(1890, 441, true),
|
||||
(1724, 400, true),
|
||||
(1657, 511, true),
|
||||
(1895, 261, true),
|
||||
(1697, 344, true),
|
||||
(933, 840, true),
|
||||
(1413, 467, true),
|
||||
(1295, 691, true),
|
||||
(1353, 548, true),
|
||||
(1293, 548, true),
|
||||
(1103, 551, true),
|
||||
(973, 758, true),
|
||||
(446, 484, false),
|
||||
(1009, 420, false),
|
||||
(144, 761, false),
|
||||
(1812, 111, false),
|
||||
(1241, 10, false),
|
||||
(900, 854, false),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (case, set, pts) in cases {
|
||||
let o = run("ec.distort.transform", &set, solid.clone());
|
||||
for &(x, y, inside) in pts {
|
||||
// Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer.
|
||||
let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]);
|
||||
let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 };
|
||||
assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}");
|
||||
}
|
||||
}
|
||||
// A huge scale stays within the buffer cap.
|
||||
let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4]));
|
||||
assert!(o.img.width <= 16_384 && o.img.height <= 16_384);
|
||||
// A layer moved far right grows the buffer on that side only, within 16 Mpx.
|
||||
let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows");
|
||||
assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01);
|
||||
let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4]));
|
||||
assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,7 +19,7 @@ use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::generate::value_noise;
|
||||
use crate::generate::value_noise_linear_z;
|
||||
use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
@@ -37,13 +37,15 @@ fn pct(max: f64) -> ParamUi {
|
||||
|
||||
// ---------------------------------------------------------------- Turbulent Displace
|
||||
|
||||
/// Fractional-octave fBm of value noise in 0..1. Evolution (`z`) is linear: with the eased z of
|
||||
/// plain value noise a few degrees of Evolution barely moved the pattern (#943).
|
||||
fn fbm(u: f64, v: f64, z: f64, seed: u32, octaves: f64, falloff: f32) -> f32 {
|
||||
let n = octaves.ceil().max(1.0) as usize;
|
||||
let frac = (octaves - octaves.floor()) as f32;
|
||||
let (mut sum, mut norm, mut amp, mut f) = (0.0f32, 0.0f32, 1.0f32, 1.0f32);
|
||||
for o in 0..n {
|
||||
let w = if o + 1 == n && frac > 0.0 { frac } else { 1.0 };
|
||||
sum += value_noise(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
|
||||
sum += value_noise_linear_z(u as f32 * f, v as f32 * f, z as f32 + o as f32 * 5.17, seed.wrapping_add(o as u32 * 101)) * amp * w;
|
||||
norm += amp * w;
|
||||
amp *= falloff;
|
||||
f *= 2.0;
|
||||
@@ -93,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let size = (ctx.params.f("size") * b.scale).max(1.0);
|
||||
if ctx.params.b("resizeLayer") && !ctx.adjustment {
|
||||
b.pad(amount.abs().ceil() as u32 + 1);
|
||||
} else {
|
||||
// Room for pixels displaced past the content, up to the layer's edge (#1205).
|
||||
b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1));
|
||||
}
|
||||
let off = b.to_px(ctx.params.v2("offset"));
|
||||
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
|
||||
@@ -1115,6 +1120,45 @@ mod tests {
|
||||
assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");
|
||||
}
|
||||
|
||||
/// #943: every non-equivalent Evolution value renders a visibly different frame. The eased
|
||||
/// evolution axis left 0 and 37 degrees (or 360 and 400) within a hundredth of each other.
|
||||
#[test]
|
||||
fn turbulent_displace_evolution_changes_the_output() {
|
||||
// A smooth ramp (red = x, green = y) with Pin All Locked, so the frame stays opaque and
|
||||
// the difference is purely how far the pattern moved.
|
||||
let mut ramp = Image::new(64, 64);
|
||||
for y in 0..64u32 {
|
||||
for x in 0..64u32 {
|
||||
ramp.set(x, y, [x as f32 / 63.0, y as f32 / 63.0, 0.25, 1.0]);
|
||||
}
|
||||
}
|
||||
let render = |evo: f64, extra: &[(&str, Value)]| {
|
||||
let base = [("evolution", num(evo)), ("size", num(10.0)), ("pinning", Value::Enum(8))];
|
||||
run("ec.distort.turbulentdisplace", &[base.as_slice(), extra].concat(), ramp.clone()).img
|
||||
};
|
||||
let evolutions = [0.0, 37.0, 90.5, 180.0, 360.0, 400.25, 725.0, 1000.5];
|
||||
let frames: Vec<Image> = evolutions.iter().map(|&e| render(e, &[])).collect();
|
||||
for (i, a) in frames.iter().enumerate() {
|
||||
for (j, b) in frames.iter().enumerate().skip(i + 1) {
|
||||
let d = max_diff(a, b);
|
||||
assert!(d > 0.01, "evolution {} and {} differ by only {d}", evolutions[i], evolutions[j]);
|
||||
}
|
||||
}
|
||||
// Constant rate: 20 degrees next to a whole revolution (from either side) is a visible
|
||||
// step, not the rounding-error size the eased axis gave (under 0.001).
|
||||
assert!(max_diff(&frames[0], &render(20.0, &[])) > 0.02);
|
||||
assert!(max_diff(&render(340.0, &[]), &frames[4]) > 0.02);
|
||||
// 0 and 360 degrees are different frames without Cycle Evolution, and the same frame with
|
||||
// Cycle (in Revolutions) = 1, at any starting angle.
|
||||
assert!(max_diff(&frames[0], &frames[4]) > 0.01);
|
||||
let cycle = [("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))];
|
||||
assert!(max_diff(&render(0.0, &cycle), &render(360.0, &cycle)) < 1e-4);
|
||||
assert!(max_diff(&render(37.0, &cycle), &render(397.0, &cycle)) < 1e-4);
|
||||
assert!(max_diff(&render(0.0, &cycle), &render(37.0, &cycle)) > 0.01);
|
||||
// Random Seed still gives a different pattern at the same Evolution.
|
||||
assert!(max_diff(&frames[1], &render(37.0, &[("evolutionOptions/randomSeed", num(5.0))])) > 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnify_blending_none_and_scaling() {
|
||||
let img = test_img(32, 32);
|
||||
|
||||
+77
-285
@@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32;
|
||||
/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance.
|
||||
fn norm3f(v: [f32; 3]) -> [f32; 3] {
|
||||
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
|
||||
[v[0] / l, v[1] / l, v[2] / l]
|
||||
}
|
||||
|
||||
fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
@@ -115,14 +122,23 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
|
||||
fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let soft = ctx.params.f("softness").max(0.0) * b.scale;
|
||||
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32;
|
||||
let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
if cut >= 1.0 {
|
||||
// Endpoint guard: at Cut Away 100 the gradient antialias must not leave a half-covered
|
||||
// pixel where an opaque source reaches exactly 1.0.
|
||||
b.img.data.fill([0.0; 4]);
|
||||
return b;
|
||||
}
|
||||
let blob = layer_or_self(ctx, &b, "blobLayer", true, false);
|
||||
let prop = src_at(ctx.params.e("property"));
|
||||
let raw = Plane::from_image(&blob, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a }
|
||||
});
|
||||
let h = gauss_plane(&raw, soft, soft);
|
||||
// Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the
|
||||
// 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness.
|
||||
// The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp.
|
||||
let h = gauss_plane(&raw, soft * 0.42, soft * 0.42);
|
||||
let intensity = ctx.params.f("lightIntensity") / 100.0;
|
||||
let lc = ctx.params.color("lightColor");
|
||||
let point = ctx.params.e("lightType") == 1;
|
||||
@@ -135,37 +151,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let rough = ctx.params.f("roughness").clamp(0.001, 1.0);
|
||||
let metal = (ctx.params.f("metal") / 100.0) as f32;
|
||||
let shininess = (1.0 / rough).min(500.0);
|
||||
let depth = (soft * 0.5).max(1.0);
|
||||
let src = b.img.clone();
|
||||
let (w, hh) = (b.img.width as usize, b.img.height as usize);
|
||||
let span = (w.max(hh) as f64).max(1.0);
|
||||
|
||||
// The source matte and the RGB refraction are separate in the oracle:
|
||||
// - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal
|
||||
// with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that;
|
||||
// - RGB follows a height-field refraction with z = height: on a linear ramp the measured
|
||||
// horizontal offset is dx = -0.34 / h, independent of Softness.
|
||||
let matte_slope = soft * 1.5;
|
||||
let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 };
|
||||
let rgb_depth = span * 0.34 * b.scale;
|
||||
let light_slope = (soft * 0.65).max(1.0);
|
||||
let limit = span * 0.5;
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (xi, yi) = (x as i64, y as i64);
|
||||
let hv = h.get(x, y);
|
||||
let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5;
|
||||
let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5;
|
||||
// Refraction-like displacement down the slope, then the blobby matte.
|
||||
let sx = x as f64 + 0.5 - gx * depth * 2.0;
|
||||
let sy = y as f64 + 0.5 - gy * depth * 2.0;
|
||||
let s = src.sample_bilinear(sx, sy);
|
||||
let (c, a) = unpremul(s);
|
||||
let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0);
|
||||
let alpha = a.max(s[3]) * m;
|
||||
let grad = (gx * gx + gy * gy).sqrt();
|
||||
// One-pixel antialiased threshold. A flat field is either fully in or fully out.
|
||||
let aa = (1.25 * grad).max(5e-4);
|
||||
let m = if grad > 1e-9 {
|
||||
(0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32
|
||||
} else if hv > cut {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if m <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
// Warp the source matte with the softness-scaled bevel normal (z = 1).
|
||||
let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]);
|
||||
let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth);
|
||||
let alpha = sa[3].max(0.0) * m;
|
||||
if alpha <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let n = norm3([-gx * depth, -gy * depth, 1.0]);
|
||||
let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir };
|
||||
let ndl = dot3(n, l).max(0.0);
|
||||
let hv3 = norm3([l[0], l[1], l[2] + 1.0]);
|
||||
let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular;
|
||||
// Refract the colour through the height field (z = height for this pass).
|
||||
let hz = (hv as f64).max(1e-3);
|
||||
let rx = (-rgb_depth * gx / hz).clamp(-limit, limit);
|
||||
let ry = (-rgb_depth * gy / hz).clamp(-limit, limit);
|
||||
let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry);
|
||||
let (c, _) = unpremul(srgb);
|
||||
let (gx32, gy32) = (gx as f32, gy as f32);
|
||||
let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]);
|
||||
let l = if point {
|
||||
norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32])
|
||||
} else {
|
||||
[ldir[0] as f32, ldir[1] as f32, ldir[2] as f32]
|
||||
};
|
||||
let ndl = dot3f(n, l).max(0.0);
|
||||
let hv3 = norm3f([l[0], l[1], l[2] + 1.0]);
|
||||
let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32;
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..3 {
|
||||
let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i];
|
||||
let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i];
|
||||
let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i];
|
||||
out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col;
|
||||
out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col;
|
||||
}
|
||||
*px = premul(out, alpha);
|
||||
}
|
||||
@@ -880,19 +928,19 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
"CC Blobbylize",
|
||||
vec![
|
||||
p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])),
|
||||
p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)),
|
||||
p("cutAway", "Cut Away", num(0.0), pct()),
|
||||
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])),
|
||||
p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)),
|
||||
p("cutAway", "Cut Away", num(25.0), pct()),
|
||||
p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)),
|
||||
p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])),
|
||||
p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point),
|
||||
p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
|
||||
p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)),
|
||||
p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
|
||||
p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)),
|
||||
p("metal", "Metal", num(100.0), pct()),
|
||||
],
|
||||
cc_blobbylize,
|
||||
@@ -1036,260 +1084,4 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, Params};
|
||||
|
||||
fn img(w: u32, h: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0])
|
||||
}
|
||||
|
||||
/// Run with Point defaults scaled to the layer (like `instantiate`).
|
||||
fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let ls = [im.width as f64, im.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for ps in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) {
|
||||
params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let env = EffectEnv { masks, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn maxd(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape {
|
||||
MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_neutral_settings() {
|
||||
let im = img(40, 30);
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![]),
|
||||
("ec.distort.ccripplepulse", vec![]),
|
||||
("ec.distort.ccslant", vec![]),
|
||||
("ec.distort.ccsmear", vec![("reach", num(0.0))]),
|
||||
("ec.distort.ccsplit", vec![("split", num(0.0))]),
|
||||
("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]),
|
||||
("ec.distort.smear", vec![]),
|
||||
("ec.distort.reshape", vec![]),
|
||||
("ec.distort.twirllegacy", vec![]),
|
||||
("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]),
|
||||
("ec.distort.upscale", vec![]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let o = run(id, set, im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity");
|
||||
assert_eq!(o.offset, [0.0, 0.0], "{id}");
|
||||
}
|
||||
// Power pin with perspective 0 at default corners is also identity.
|
||||
let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deterministic() {
|
||||
let im = img(36, 28);
|
||||
let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)];
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]),
|
||||
("ec.distort.ccblobbylize", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]),
|
||||
("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]),
|
||||
("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]),
|
||||
("ec.distort.ccsmear", vec![]),
|
||||
("ec.distort.ccsplit", vec![]),
|
||||
("ec.distort.ccsplit2", vec![("split2", num(10.0))]),
|
||||
("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]),
|
||||
("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]),
|
||||
("ec.distort.twirllegacy", vec![("angle", num(90.0))]),
|
||||
("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]),
|
||||
("ec.distort.upscale", vec![("scale", num(200.0))]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let a = run(id, set, im.clone(), &masks);
|
||||
let b = run(id, set, im.clone(), &masks);
|
||||
assert_eq!(a.img, b.img, "{id}");
|
||||
assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}");
|
||||
assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
|
||||
/// (200 × 100) layer bounds, after `id` with `set`.
|
||||
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let mut im = Image::new(20, 10);
|
||||
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
/// Alpha at layer point `p` (0 outside the buffer).
|
||||
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
|
||||
let (x, y) = b.to_px(p);
|
||||
b.img.get(x.floor() as i64, y.floor() as i64)[3]
|
||||
}
|
||||
|
||||
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
|
||||
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
|
||||
#[test]
|
||||
fn effects_on_text_reach_past_its_box() {
|
||||
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
|
||||
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
|
||||
assert!(smear.img.width < 200, "it grows only as far as pixels move");
|
||||
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
|
||||
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
|
||||
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
|
||||
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
|
||||
// A footage layer's buffer is its bounds already: no change.
|
||||
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
|
||||
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_warp_style_runs_and_bends() {
|
||||
let im = img(40, 30);
|
||||
for s in 0..WARP_STYLES.len() as u32 {
|
||||
for axis in 0..2 {
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as u32;
|
||||
// The layer centre's neighbourhood is still made of source pixels.
|
||||
assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}");
|
||||
assert!(o.img.width > im.width);
|
||||
}
|
||||
}
|
||||
// Arc with positive bend raises the middle of the layer: the top edge in the middle
|
||||
// moves up (opaque pixels above the original top).
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[1] as i64;
|
||||
assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5);
|
||||
assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_moves_corners() {
|
||||
let im = img(20, 20);
|
||||
// Pin to the right half: output x in 10..20 shows the whole layer squeezed.
|
||||
let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))];
|
||||
let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(o.img.get(3, 10)[3], 0.0);
|
||||
let px = o.img.get(15, 10);
|
||||
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_unstretch_expands_pinned_region() {
|
||||
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
|
||||
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
|
||||
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
|
||||
assert_eq!(opaque(&off), 96);
|
||||
let mut set = inset.to_vec();
|
||||
set.push(("unstretch", Value::Bool(true)));
|
||||
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(opaque(&on), 24 * 16);
|
||||
assert!(on.img.get(0, 0)[3] > 0.99);
|
||||
// Default corners are the identity either way.
|
||||
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
|
||||
assert!(maxd(&id.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_opens_a_gap() {
|
||||
let im = img(40, 30);
|
||||
let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]);
|
||||
assert_eq!(o.img.get(20, 15)[3], 0.0);
|
||||
assert_eq!(o.img.get(2, 2), im.get(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slant_shears_from_floor() {
|
||||
let im = img(20, 20);
|
||||
let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as i64;
|
||||
// Bottom row stays put; the top row shifts right by ~10 px.
|
||||
assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05);
|
||||
assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_source_to_destination() {
|
||||
// A bright square at the source mask; reshape to a shifted destination mask.
|
||||
let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks);
|
||||
assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15));
|
||||
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_steer_the_match() {
|
||||
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
|
||||
let im = img(40, 40);
|
||||
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
|
||||
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
|
||||
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&twisted.img, &im) > 0.1);
|
||||
// Fraction mapping.
|
||||
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_grows_about_centre() {
|
||||
let im = img(20, 10);
|
||||
let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]);
|
||||
assert_eq!((o.img.width, o.img.height), (40, 20));
|
||||
// Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the
|
||||
// centre (10,5) lands in the middle of the 40×20 buffer.
|
||||
assert_eq!(o.offset, [10.0, 5.0]);
|
||||
let c = o.img.sample_bilinear(20.0, 10.0);
|
||||
assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_legacy_rotates_inside_radius() {
|
||||
let im = img(40, 40);
|
||||
let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) > 0.1);
|
||||
// Corners lie outside the radius.
|
||||
assert_eq!(o.img.get(0, 0), im.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_resolution_matches_scaled_geometry() {
|
||||
// Bender at scale 0.5: the buffer is half size, parameters in layer pixels.
|
||||
let s = crate::find("ec.distort.ccbender").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("amount".into(), num(30.0));
|
||||
params.values.insert("top".into(), pt(20.0, 0.0));
|
||||
params.values.insert("base".into(), pt(20.0, 40.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 });
|
||||
let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 });
|
||||
let a = full.img.sample_bilinear(20.0, 4.0)[3];
|
||||
let b = half.img.sample_bilinear(10.0, 2.0)[3];
|
||||
assert!((a - b).abs() < 0.3);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,332 @@
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, Params};
|
||||
|
||||
fn img(w: u32, h: u32) -> Image {
|
||||
crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0])
|
||||
}
|
||||
|
||||
/// Run with Point defaults scaled to the layer (like `instantiate`).
|
||||
fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let ls = [im.width as f64, im.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for ps in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) {
|
||||
params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let env = EffectEnv { masks, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
fn maxd(a: &Image, b: &Image) -> f32 {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape {
|
||||
MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_at_neutral_settings() {
|
||||
let im = img(40, 30);
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![]),
|
||||
("ec.distort.ccripplepulse", vec![]),
|
||||
("ec.distort.ccslant", vec![]),
|
||||
("ec.distort.ccsmear", vec![("reach", num(0.0))]),
|
||||
("ec.distort.ccsplit", vec![("split", num(0.0))]),
|
||||
("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]),
|
||||
("ec.distort.smear", vec![]),
|
||||
("ec.distort.reshape", vec![]),
|
||||
("ec.distort.twirllegacy", vec![]),
|
||||
("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]),
|
||||
("ec.distort.upscale", vec![]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let o = run(id, set, im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity");
|
||||
assert_eq!(o.offset, [0.0, 0.0], "{id}");
|
||||
}
|
||||
// Power pin with perspective 0 at default corners is also identity.
|
||||
let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deterministic() {
|
||||
let im = img(36, 28);
|
||||
let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)];
|
||||
let cases: &[(&str, Vec<(&str, Value)>)] = &[
|
||||
("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]),
|
||||
("ec.distort.ccblobbylize", vec![]),
|
||||
("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]),
|
||||
("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]),
|
||||
("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]),
|
||||
("ec.distort.ccsmear", vec![]),
|
||||
("ec.distort.ccsplit", vec![]),
|
||||
("ec.distort.ccsplit2", vec![("split2", num(10.0))]),
|
||||
("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]),
|
||||
("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]),
|
||||
("ec.distort.twirllegacy", vec![("angle", num(90.0))]),
|
||||
("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]),
|
||||
("ec.distort.upscale", vec![("scale", num(200.0))]),
|
||||
];
|
||||
for (id, set) in cases {
|
||||
let a = run(id, set, im.clone(), &masks);
|
||||
let b = run(id, set, im.clone(), &masks);
|
||||
assert_eq!(a.img, b.img, "{id}");
|
||||
assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}");
|
||||
assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_defaults_match_ae() {
|
||||
let s = crate::find("ec.distort.ccblobbylize").unwrap();
|
||||
let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone();
|
||||
assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red");
|
||||
assert_eq!(default("softness"), num(20.0));
|
||||
assert_eq!(default("cutAway"), num(25.0));
|
||||
assert_eq!(default("lightHeight"), num(65.0));
|
||||
assert_eq!(default("lightPosition"), pt(0.5, 0.5));
|
||||
assert_eq!(default("ambient"), num(75.0));
|
||||
assert_eq!(default("diffuse"), num(25.0));
|
||||
assert_eq!(default("specular"), num(100.0));
|
||||
assert_eq!(default("roughness"), num(0.025));
|
||||
assert_eq!(default("metal"), num(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_thresholds_a_height_ramp() {
|
||||
// AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]);
|
||||
assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent");
|
||||
assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque");
|
||||
let edge = o.img.get(480, 4)[3];
|
||||
assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold");
|
||||
let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count();
|
||||
assert!(partial <= 2, "{partial} antialiased columns instead of one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_is_empty() {
|
||||
// Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100.
|
||||
let im = crate::util::gen_image(1920, 8, |x, _| {
|
||||
let h = x as f32 / 1920.0;
|
||||
[h, h, h, 1.0]
|
||||
});
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_cut_away_100_clears_an_opaque_edge() {
|
||||
// An opaque source reaches h = 1 on the high side of an edge; the gradient antialias
|
||||
// used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels.
|
||||
let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]);
|
||||
assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blobbylize_softness_warps_a_step_matte_inward() {
|
||||
// AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte
|
||||
// edge at about x = 946, not at the source edge. The PR's matte-preserving version
|
||||
// left it at 960; the effect must warp the source matte.
|
||||
let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] });
|
||||
let o = run(
|
||||
"ec.distort.ccblobbylize",
|
||||
&[
|
||||
("property", Value::Enum(3)),
|
||||
("softness", num(20.0)),
|
||||
("cutAway", num(0.0)),
|
||||
("ambient", num(100.0)),
|
||||
("diffuse", num(0.0)),
|
||||
("specular", num(0.0)),
|
||||
("metal", num(0.0)),
|
||||
],
|
||||
im,
|
||||
&[],
|
||||
);
|
||||
assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]);
|
||||
assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge");
|
||||
assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped");
|
||||
}
|
||||
|
||||
/// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized
|
||||
/// (200 × 100) layer bounds, after `id` with `set`.
|
||||
fn on_text(id: &str, set: &[(&str, Value)]) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
for (k, v) in set {
|
||||
assert!(params.values.contains_key(*k), "{id}: {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let mut im = Image::new(20, 10);
|
||||
im.data.iter_mut().for_each(|p| *p = [1.0; 4]);
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
/// Alpha at layer point `p` (0 outside the buffer).
|
||||
fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 {
|
||||
let (x, y) = b.to_px(p);
|
||||
b.img.get(x.floor() as i64, y.floor() as i64)[3]
|
||||
}
|
||||
|
||||
/// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds
|
||||
/// (comp-sized, as in After Effects), not cut off where the glyphs end.
|
||||
#[test]
|
||||
fn effects_on_text_reach_past_its_box() {
|
||||
let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]);
|
||||
assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box");
|
||||
assert!(smear.img.width < 200, "it grows only as far as pixels move");
|
||||
let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]);
|
||||
assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9);
|
||||
assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds");
|
||||
assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1);
|
||||
// A footage layer's buffer is its bounds already: no change.
|
||||
let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]);
|
||||
assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_warp_style_runs_and_bends() {
|
||||
let im = img(40, 30);
|
||||
for s in 0..WARP_STYLES.len() as u32 {
|
||||
for axis in 0..2 {
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as u32;
|
||||
// The layer centre's neighbourhood is still made of source pixels.
|
||||
assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}");
|
||||
assert!(o.img.width > im.width);
|
||||
}
|
||||
}
|
||||
// Arc with positive bend raises the middle of the layer: the top edge in the middle
|
||||
// moves up (opaque pixels above the original top).
|
||||
let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[1] as i64;
|
||||
assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5);
|
||||
assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_moves_corners() {
|
||||
let im = img(20, 20);
|
||||
// Pin to the right half: output x in 10..20 shows the whole layer squeezed.
|
||||
let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))];
|
||||
let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(o.img.get(3, 10)[3], 0.0);
|
||||
let px = o.img.get(15, 10);
|
||||
assert!((px[0] - 0.5).abs() < 0.1, "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_pin_unstretch_expands_pinned_region() {
|
||||
let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]);
|
||||
let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))];
|
||||
let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count();
|
||||
let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]);
|
||||
assert_eq!(opaque(&off), 96);
|
||||
let mut set = inset.to_vec();
|
||||
set.push(("unstretch", Value::Bool(true)));
|
||||
let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]);
|
||||
assert_eq!(opaque(&on), 24 * 16);
|
||||
assert!(on.img.get(0, 0)[3] > 0.99);
|
||||
// Default corners are the identity either way.
|
||||
let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]);
|
||||
assert!(maxd(&id.img, &im) < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_opens_a_gap() {
|
||||
let im = img(40, 30);
|
||||
let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]);
|
||||
assert_eq!(o.img.get(20, 15)[3], 0.0);
|
||||
assert_eq!(o.img.get(2, 2), im.get(2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slant_shears_from_floor() {
|
||||
let im = img(20, 20);
|
||||
let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]);
|
||||
let pad = o.offset[0] as i64;
|
||||
// Bottom row stays put; the top row shifts right by ~10 px.
|
||||
assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05);
|
||||
assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_source_to_destination() {
|
||||
// A bright square at the source mask; reshape to a shifted destination mask.
|
||||
let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks);
|
||||
assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15));
|
||||
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_steer_the_match() {
|
||||
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
|
||||
let im = img(40, 40);
|
||||
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
|
||||
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
|
||||
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&twisted.img, &im) > 0.1);
|
||||
// Fraction mapping.
|
||||
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_grows_about_centre() {
|
||||
let im = img(20, 10);
|
||||
let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]);
|
||||
assert_eq!((o.img.width, o.img.height), (40, 20));
|
||||
// Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the
|
||||
// centre (10,5) lands in the middle of the 40×20 buffer.
|
||||
assert_eq!(o.offset, [10.0, 5.0]);
|
||||
let c = o.img.sample_bilinear(20.0, 10.0);
|
||||
assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twirl_legacy_rotates_inside_radius() {
|
||||
let im = img(40, 40);
|
||||
let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]);
|
||||
assert!(maxd(&o.img, &im) > 0.1);
|
||||
// Corners lie outside the radius.
|
||||
assert_eq!(o.img.get(0, 0), im.get(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_resolution_matches_scaled_geometry() {
|
||||
// Bender at scale 0.5: the buffer is half size, parameters in layer pixels.
|
||||
let s = crate::find("ec.distort.ccbender").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("amount".into(), num(30.0));
|
||||
params.values.insert("top".into(), pt(20.0, 0.0));
|
||||
params.values.insert("base".into(), pt(20.0, 40.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 });
|
||||
let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 });
|
||||
let a = full.img.sample_bilinear(20.0, 4.0)[3];
|
||||
let b = half.img.sample_bilinear(10.0, 2.0)[3];
|
||||
assert!((a - b).abs() < 0.3);
|
||||
}
|
||||
@@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px {
|
||||
}
|
||||
}
|
||||
|
||||
/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point
|
||||
/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100
|
||||
/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error
|
||||
/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0.
|
||||
pub fn four_color_softening(blend: f64, scale: f64) -> f64 {
|
||||
let b = blend.max(0.0) * scale;
|
||||
(b * b).max(1e-6)
|
||||
}
|
||||
|
||||
fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pts: Vec<((f64, f64), [f32; 4])> =
|
||||
(1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect();
|
||||
let blend = ctx.params.f("blend").max(1.0);
|
||||
let k = four_color_softening(ctx.params.f("blend"), b.scale);
|
||||
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let op = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let mode = ctx.params.e("blendingMode");
|
||||
@@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let mut wsum = 0.0;
|
||||
for ((qx, qy), c) in &pts {
|
||||
let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2);
|
||||
let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5);
|
||||
let w = 1.0 / (d2 + k);
|
||||
for i in 0..3 {
|
||||
acc[i] += c[i] as f64 * w;
|
||||
}
|
||||
@@ -283,10 +292,22 @@ pub(crate) fn fade(t: f32) -> f32 {
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// 3D value noise in 0..1 (z = evolution).
|
||||
/// 3D value noise in 0..1 (z = evolution). The quintic fade applies on all three axes, so z
|
||||
/// eases in and out of every integer.
|
||||
pub fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
value_noise_z(x, y, z, seed, true)
|
||||
}
|
||||
|
||||
/// [`value_noise`] with the evolution axis z interpolated linearly (x and y keep the quintic
|
||||
/// fade). The fade's zero slope at every integer z made a small Evolution change almost invisible
|
||||
/// next to a whole revolution (#943, #1816); linear z moves the pattern at a constant rate.
|
||||
pub fn value_noise_linear_z(x: f32, y: f32, z: f32, seed: u32) -> f32 {
|
||||
value_noise_z(x, y, z, seed, false)
|
||||
}
|
||||
|
||||
fn value_noise_z(x: f32, y: f32, z: f32, seed: u32, ease_z: bool) -> f32 {
|
||||
let (x0, y0, z0) = (x.floor(), y.floor(), z.floor());
|
||||
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), fade(z - z0));
|
||||
let (fx, fy, fz) = (fade(x - x0), fade(y - y0), if ease_z { fade(z - z0) } else { z - z0 });
|
||||
let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32);
|
||||
let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
|
||||
let c = |dx: i32, dy: i32, dz: i32| lattice(ix + dx, iy + dy, iz + dz, seed);
|
||||
@@ -405,6 +426,27 @@ mod generator_tests {
|
||||
Image::filled(32, 32, [1.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
/// #943: the evolution axis is linear, so z moves the noise at a constant rate, while the
|
||||
/// classic `value_noise` keeps its eased z (other effects and their pinned goldens use it).
|
||||
#[test]
|
||||
fn linear_z_value_noise_moves_at_a_constant_rate() {
|
||||
for (x, y, seed) in [(0.3f32, 0.7f32, 5u32), (2.4, -1.6, 11), (-3.1, 4.9, 0)] {
|
||||
let a = value_noise_linear_z(x, y, 0.0, seed);
|
||||
let b = value_noise_linear_z(x, y, 1.0, seed);
|
||||
for z in [0.1f32, 0.25, 0.5, 0.75, 0.9] {
|
||||
let want = a + (b - a) * z;
|
||||
let got = value_noise_linear_z(x, y, z, seed);
|
||||
assert!((got - want).abs() < 1e-5, "({x}, {y}) z {z}: {got} vs {want}");
|
||||
}
|
||||
// Same lattice values at whole z, and x / y are untouched.
|
||||
assert_eq!(value_noise_linear_z(x, y, 2.0, seed), value_noise(x, y, 2.0, seed));
|
||||
}
|
||||
// The eased version still has the old shape: at z = 0.25 it lags the linear one.
|
||||
let (a, b) = (value_noise(0.3, 0.7, 0.0, 5), value_noise(0.3, 0.7, 1.0, 5));
|
||||
let eased = value_noise(0.3, 0.7, 0.25, 5);
|
||||
assert!((eased - (a + (b - a) * 0.25)).abs() > 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_ramp_stays_inside_the_layer_alpha() {
|
||||
// #1063: a shape's transparent pixels stay transparent; the ramp fills only the shape.
|
||||
@@ -423,6 +465,59 @@ mod generator_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 %
|
||||
/// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10
|
||||
/// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5).
|
||||
#[test]
|
||||
fn four_color_blend_matches_after_effects() {
|
||||
let pts = [
|
||||
("positionsColors/point1", [19.2, 10.8]),
|
||||
("positionsColors/point2", [172.8, 10.8]),
|
||||
("positionsColors/point3", [19.2, 97.2]),
|
||||
("positionsColors/point4", [172.8, 97.2]),
|
||||
];
|
||||
let cols = [
|
||||
("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]),
|
||||
("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]),
|
||||
("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]),
|
||||
];
|
||||
let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [
|
||||
(
|
||||
10.0,
|
||||
[
|
||||
(30, 20, [249.0, 240.0, 15.0]),
|
||||
(150, 30, [20.0, 208.0, 47.0]),
|
||||
(60, 90, [213.0, 56.0, 199.0]),
|
||||
(100, 10, [115.0, 177.0, 78.0]),
|
||||
(10, 50, [238.0, 145.0, 110.0]),
|
||||
],
|
||||
),
|
||||
(
|
||||
30.0,
|
||||
[
|
||||
(30, 20, [236.0, 214.0, 41.0]),
|
||||
(150, 30, [30.0, 189.0, 66.0]),
|
||||
(60, 90, [203.0, 67.0, 188.0]),
|
||||
(100, 10, [117.0, 173.0, 82.0]),
|
||||
(10, 50, [233.0, 140.0, 115.0]),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (blend, samples) in ae {
|
||||
let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect();
|
||||
vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v))));
|
||||
vals.push(("blend", num(blend)));
|
||||
let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default());
|
||||
for (x, y, want) in samples {
|
||||
let p = o.img.get(x, y);
|
||||
for c in 0..3 {
|
||||
assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_color_none_stays_inside_the_layer_alpha() {
|
||||
// #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box.
|
||||
|
||||
+30
-326
@@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [
|
||||
"Tubular",
|
||||
];
|
||||
|
||||
/// Shaping used for Cell Pattern option `i` (see [`cell_value`]).
|
||||
/// Shaping used for Cell Pattern option `i` (see `cell_value`).
|
||||
pub fn pattern_kind(i: u32) -> u32 {
|
||||
match i {
|
||||
0..=5 => i,
|
||||
@@ -692,18 +692,33 @@ impl BoltCfg<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
|
||||
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
|
||||
if out.len() > 20_000 {
|
||||
/// One branch's geometry and recursive growth state.
|
||||
struct BoltBranch {
|
||||
start: (f64, f64),
|
||||
end: (f64, f64),
|
||||
depth: u32,
|
||||
intensity: f32,
|
||||
main: bool,
|
||||
}
|
||||
|
||||
/// Segments and their matching main-core flags, kept in the same traversal order.
|
||||
#[derive(Default)]
|
||||
struct BoltOutput {
|
||||
segments: Vec<Seg>,
|
||||
mains: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Grow a branch by midpoint displacement, recursively appending its segments and forks.
|
||||
fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) {
|
||||
let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch;
|
||||
if output.segments.len() > 20_000 {
|
||||
return;
|
||||
}
|
||||
let (dx, dy) = (z.0 - a.0, z.1 - a.1);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if depth == 0 || len < 1.0 {
|
||||
out.push((a, z, inten));
|
||||
mains.push(main);
|
||||
output.segments.push((a, z, inten));
|
||||
output.mains.push(main);
|
||||
return;
|
||||
}
|
||||
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
|
||||
@@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
|
||||
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
|
||||
return;
|
||||
}
|
||||
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
|
||||
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
|
||||
bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output);
|
||||
bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output);
|
||||
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
|
||||
let ang = rng.s() * 0.7;
|
||||
let (s, c) = ang.sin_cos();
|
||||
@@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai
|
||||
let k = 1.2;
|
||||
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
|
||||
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
|
||||
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
|
||||
bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
||||
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
};
|
||||
let mut segs = Vec::new();
|
||||
let mut mains = Vec::new();
|
||||
let mut output = BoltOutput::default();
|
||||
let (h, w) = (b.img.height as f64, b.img.width as f64);
|
||||
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
|
||||
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output);
|
||||
// LIGHTNING_TYPES order.
|
||||
match kind {
|
||||
// Direction: travels toward the Direction point and beyond.
|
||||
@@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
||||
// Strike.
|
||||
_ => go(o, d, depth, &cfg, &mut rng),
|
||||
}
|
||||
let BoltOutput { segments: mut segs, mains } = output;
|
||||
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
|
||||
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
|
||||
// Decay Main Core: the whole bolt fades with distance from the origin.
|
||||
@@ -1223,315 +1238,4 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Params;
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
let ls = [img.width as f64, img.height as f64];
|
||||
for p in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) {
|
||||
params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_is_deterministic_and_seeded() {
|
||||
let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
assert_eq!(a.img, b2.img);
|
||||
let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30));
|
||||
assert_ne!(a.img, c.img);
|
||||
assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_ring_coverage() {
|
||||
let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64));
|
||||
// Ring passes through (32 + 20, 32); centre is empty.
|
||||
assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32));
|
||||
assert!(o.img.get(32, 32)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 2)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_is_deterministic_per_conductivity() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
assert_eq!(a.img, b2.img);
|
||||
assert!(a.img.data.iter().any(|p| p[3] > 0.5));
|
||||
let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48));
|
||||
assert_ne!(a.img, c.img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beam_draws_between_points() {
|
||||
let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32));
|
||||
assert!(o.img.get(32, 16)[3] > 0.99);
|
||||
assert!(o.img.get(32, 2)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 16)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_tiles() {
|
||||
// 4×4-cell tiles of 8 px: the pattern repeats every 32 px.
|
||||
let set = [
|
||||
("size", num(8.0)),
|
||||
("tilingOptions/enableTiling", Value::Bool(true)),
|
||||
("tilingOptions/cellsHorizontal", num(4.0)),
|
||||
("tilingOptions/cellsVertical", num(4.0)),
|
||||
];
|
||||
let o = run("ec.generate.cellpattern", &set, Image::new(64, 64));
|
||||
for (x, y) in [(3, 5), (10, 20), (17, 2)] {
|
||||
let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32));
|
||||
assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_expansion_velocity_and_star() {
|
||||
let r = |set: &[(&str, Value)], t: f64| {
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 })
|
||||
};
|
||||
// One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px.
|
||||
let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))];
|
||||
let o = r(&base, 0.2);
|
||||
assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32));
|
||||
assert!(o.img.get(32, 32)[3] < 0.01);
|
||||
// Velocity moves the wave toward Direction (90° = right).
|
||||
let mut moving = base.to_vec();
|
||||
moving.push(("waveMotion/velocity", num(50.0)));
|
||||
let o = r(&moving, 0.2);
|
||||
assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32));
|
||||
// A 4-point star: the outline dips toward the centre between the points.
|
||||
let mut star = base.to_vec();
|
||||
star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]);
|
||||
let o = r(&star, 0.2);
|
||||
assert!(o.img.get(32, 12)[3] > 0.9, "point straight up");
|
||||
// Between points (45°) the outline sits near half the radius.
|
||||
assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24));
|
||||
assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_alpha_obstacle_and_expert_settings() {
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::<f32>();
|
||||
// A wall of opaque pixels across the middle of the bolt's path.
|
||||
let mut wall = Image::new(64, 48);
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
if (18..30).contains(&y) && x < 50 {
|
||||
wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let opts = |o: f64| {
|
||||
vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]
|
||||
};
|
||||
let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
|
||||
let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
|
||||
assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
|
||||
// Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
|
||||
let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
for (id, v) in [
|
||||
("expertSettings/fractalType", Value::Enum(2)),
|
||||
("expertSettings/coreDrain", num(100.0)),
|
||||
("expertSettings/forkStrength", num(100.0)),
|
||||
("expertSettings/forkVariation", num(100.0)),
|
||||
] {
|
||||
assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
|
||||
}
|
||||
let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
|
||||
assert!(sum(&drained.img) < sum(&base.img));
|
||||
let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
|
||||
let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
|
||||
assert_eq!(no_forks.img, term.img, "no forks either way");
|
||||
assert!(sum(&no_forks.img) < sum(&base.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_types_and_decay_main_core() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48));
|
||||
assert_eq!(a.img, strike.img, "Strike is the default");
|
||||
for t in 0..8 {
|
||||
let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48));
|
||||
assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws");
|
||||
}
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48));
|
||||
let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48));
|
||||
assert!(sum(&d.img) < sum(&n.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beam_3d_perspective_controls_thickness() {
|
||||
// Short beam near the start: with 3D Perspective its thickness comes from its position
|
||||
// (thin, near the start); without, its head takes the full ending thickness.
|
||||
let set = |p: bool| {
|
||||
vec![
|
||||
("length", num(20.0)),
|
||||
("time", num(0.0)),
|
||||
("softness", num(0.0)),
|
||||
("startThickness", num(2.0)),
|
||||
("endThickness", num(20.0)),
|
||||
("perspective3d", Value::Bool(p)),
|
||||
]
|
||||
};
|
||||
let on = run("ec.generate.beam", &set(true), Image::new(64, 32));
|
||||
let off = run("ec.generate.beam", &set(false), Image::new(64, 32));
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_flare_brightest_at_center() {
|
||||
let o = run("ec.generate.lensflare", &[], Image::new(64, 64));
|
||||
let c = o.img.get(16, 16)[0];
|
||||
assert!(c > o.img.get(60, 4)[0]);
|
||||
assert!(c > 0.5);
|
||||
}
|
||||
|
||||
fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
|
||||
let mut v: Vec<(&str, Value)> =
|
||||
vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
|
||||
v.extend_from_slice(set);
|
||||
crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_distance_of_a_square() {
|
||||
let (w, h) = (20, 20);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
|
||||
let d = crate::util::signed_distance(&inside, w, h);
|
||||
assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
|
||||
assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
|
||||
assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_mask_contours_profile_reflection_and_birth() {
|
||||
// Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
|
||||
let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
|
||||
let masks = [sq];
|
||||
let env = crate::EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
|
||||
assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
|
||||
assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
|
||||
// Rounded corner: diagonal distance 10 from (37, 37).
|
||||
assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
|
||||
// No mask chosen: nothing drawn.
|
||||
assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
|
||||
// Image Contours: the bright square of a source layer.
|
||||
struct Src;
|
||||
impl crate::EffectHost for Src {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let mut img = Image::new(64, 64);
|
||||
for y in 27..37 {
|
||||
for x in 27..37 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
// Colour animates from red (born at 0) to green (born later).
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
p.values.insert("waveMotion/frequency".into(), num(1.0));
|
||||
p.values.insert("waveMotion/expansion".into(), num(20.0));
|
||||
p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
|
||||
let ic = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
|
||||
// Source Center off the square shifts the contour onto the producer point accordingly.
|
||||
let sh = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
|
||||
// Profile: Taper Both thins the stroke edges; Square is solid across.
|
||||
let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
|
||||
let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
|
||||
let mut tap = wide.to_vec();
|
||||
tap.push(("waveStroke/profile", Value::Enum(3)));
|
||||
let tap = rw(&tap, 0.2, crate::EffectEnv::default());
|
||||
assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
|
||||
assert!(tap.get(52, 32)[3] > 0.8);
|
||||
// Reflection: a fast wave bounces back off the right edge.
|
||||
let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
|
||||
let gone = rw(&fast, 0.3, crate::EffectEnv::default());
|
||||
let mut rf = fast.to_vec();
|
||||
rf.push(("waveMotion/reflection", Value::Bool(true)));
|
||||
let back = rw(&rf, 0.3, crate::EffectEnv::default());
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
|
||||
// Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
|
||||
let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
|
||||
let (old, new) = (both.get(55, 32), both.get(35, 32));
|
||||
assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
|
||||
assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_hq_variants_antialias() {
|
||||
let s = crate::find("ec.generate.cellpattern").unwrap();
|
||||
let r = |pat: u32| {
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("cellPattern".into(), Value::Enum(pat));
|
||||
params.values.insert("size".into(), num(12.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
|
||||
};
|
||||
let distinct = |img: &Image| {
|
||||
let mut v: Vec<u32> = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v.len()
|
||||
};
|
||||
let (std, hq) = (r(4), r(9));
|
||||
assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
|
||||
assert_ne!(std, hq);
|
||||
assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,309 @@
|
||||
use super::*;
|
||||
use crate::Params;
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
let ls = [img.width as f64, img.height as f64];
|
||||
for p in &s.params {
|
||||
if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) {
|
||||
params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]]));
|
||||
}
|
||||
}
|
||||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_is_deterministic_and_seeded() {
|
||||
let a = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
let b2 = run("ec.generate.cellpattern", &[("size", num(10.0))], Image::new(40, 30));
|
||||
assert_eq!(a.img, b2.img);
|
||||
let c = run("ec.generate.cellpattern", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], Image::new(40, 30));
|
||||
assert_ne!(a.img, c.img);
|
||||
assert!(a.img.data.iter().all(|p| p[3] == 1.0 && (0.0..=1.0).contains(&p[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_ring_coverage() {
|
||||
let o = run("ec.generate.ellipse", &[("width", num(40.0)), ("height", num(40.0)), ("thickness", num(6.0))], Image::new(64, 64));
|
||||
// Ring passes through (32 + 20, 32); centre is empty.
|
||||
assert!(o.img.get(51, 32)[3] > 0.9, "{:?}", o.img.get(51, 32));
|
||||
assert!(o.img.get(32, 32)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 2)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_is_deterministic_per_conductivity() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
let b2 = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
assert_eq!(a.img, b2.img);
|
||||
assert!(a.img.data.iter().any(|p| p[3] > 0.5));
|
||||
let c = run("ec.generate.advancedlightning", &[("conductivityState", num(11.0))], Image::new(64, 48));
|
||||
assert_ne!(a.img, c.img);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beam_draws_between_points() {
|
||||
let o = run("ec.generate.beam", &[("length", num(100.0)), ("softness", num(0.0))], Image::new(64, 32));
|
||||
assert!(o.img.get(32, 16)[3] > 0.99);
|
||||
assert!(o.img.get(32, 2)[3] < 1e-3);
|
||||
assert!(o.img.get(2, 16)[3] < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_tiles() {
|
||||
// 4×4-cell tiles of 8 px: the pattern repeats every 32 px.
|
||||
let set = [
|
||||
("size", num(8.0)),
|
||||
("tilingOptions/enableTiling", Value::Bool(true)),
|
||||
("tilingOptions/cellsHorizontal", num(4.0)),
|
||||
("tilingOptions/cellsVertical", num(4.0)),
|
||||
];
|
||||
let o = run("ec.generate.cellpattern", &set, Image::new(64, 64));
|
||||
for (x, y) in [(3, 5), (10, 20), (17, 2)] {
|
||||
let (a, b2) = (o.img.get(x, y), o.img.get(x + 32, y + 32));
|
||||
assert!((a[0] - b2[0]).abs() < 1e-4, "{x},{y}: {a:?} vs {b2:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_expansion_velocity_and_star() {
|
||||
let r = |set: &[(&str, Value)], t: f64| {
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [64.0, 64.0], seed: 3, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: Image::new(64, 64), offset: [0.0, 0.0], scale: 1.0 })
|
||||
};
|
||||
// One wave born at t = 0, 0.2 s old: radius = Expansion × age = 20 px.
|
||||
let base = [("waveMotion/expansion", num(100.0)), ("waveMotion/frequency", num(0.5))];
|
||||
let o = r(&base, 0.2);
|
||||
assert!(o.img.get(52, 32)[3] > 0.9, "{:?}", o.img.get(52, 32));
|
||||
assert!(o.img.get(32, 32)[3] < 0.01);
|
||||
// Velocity moves the wave toward Direction (90° = right).
|
||||
let mut moving = base.to_vec();
|
||||
moving.push(("waveMotion/velocity", num(50.0)));
|
||||
let o = r(&moving, 0.2);
|
||||
assert!(o.img.get(62, 32)[3] > 0.9 && o.img.get(52, 32)[3] < 0.5, "{:?}", o.img.get(62, 32));
|
||||
// A 4-point star: the outline dips toward the centre between the points.
|
||||
let mut star = base.to_vec();
|
||||
star.extend([("polygon/sides", num(4.0)), ("polygon/star", Value::Bool(true)), ("polygon/starDepth", num(-0.5))]);
|
||||
let o = r(&star, 0.2);
|
||||
assert!(o.img.get(32, 12)[3] > 0.9, "point straight up");
|
||||
// Between points (45°) the outline sits near half the radius.
|
||||
assert!(o.img.get(39, 24)[3] > 0.5, "{:?}", o.img.get(39, 24));
|
||||
assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_alpha_obstacle_and_expert_settings() {
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::<f32>();
|
||||
// A wall of opaque pixels across the middle of the bolt's path.
|
||||
let mut wall = Image::new(64, 48);
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
if (18..30).contains(&y) && x < 50 {
|
||||
wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let opts =
|
||||
|o: f64| vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))];
|
||||
let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
|
||||
let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
|
||||
assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
|
||||
// Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
|
||||
let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
for (id, v) in [
|
||||
("expertSettings/fractalType", Value::Enum(2)),
|
||||
("expertSettings/coreDrain", num(100.0)),
|
||||
("expertSettings/forkStrength", num(100.0)),
|
||||
("expertSettings/forkVariation", num(100.0)),
|
||||
] {
|
||||
assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
|
||||
}
|
||||
let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
|
||||
assert!(sum(&drained.img) < sum(&base.img));
|
||||
let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
|
||||
let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
|
||||
assert_eq!(no_forks.img, term.img, "no forks either way");
|
||||
assert!(sum(&no_forks.img) < sum(&base.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_types_and_decay_main_core() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
let strike = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(1))], Image::new(64, 48));
|
||||
assert_eq!(a.img, strike.img, "Strike is the default");
|
||||
for t in 0..8 {
|
||||
let o = run("ec.generate.advancedlightning", &[("lightningType", Value::Enum(t))], Image::new(64, 48));
|
||||
assert!(o.img.data.iter().any(|p| p[3] > 0.3), "type {t} draws");
|
||||
}
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let d = run("ec.generate.advancedlightning", &[("decayMainCore", Value::Bool(true)), ("decay", num(0.9))], Image::new(64, 48));
|
||||
let n = run("ec.generate.advancedlightning", &[("decay", num(0.9))], Image::new(64, 48));
|
||||
assert!(sum(&d.img) < sum(&n.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beam_3d_perspective_controls_thickness() {
|
||||
// Short beam near the start: with 3D Perspective its thickness comes from its position
|
||||
// (thin, near the start); without, its head takes the full ending thickness.
|
||||
let set = |p: bool| {
|
||||
vec![
|
||||
("length", num(20.0)),
|
||||
("time", num(0.0)),
|
||||
("softness", num(0.0)),
|
||||
("startThickness", num(2.0)),
|
||||
("endThickness", num(20.0)),
|
||||
("perspective3d", Value::Bool(p)),
|
||||
]
|
||||
};
|
||||
let on = run("ec.generate.beam", &set(true), Image::new(64, 32));
|
||||
let off = run("ec.generate.beam", &set(false), Image::new(64, 32));
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&off.img) > sum(&on.img) * 1.5, "{} {}", sum(&off.img), sum(&on.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_flare_brightest_at_center() {
|
||||
let o = run("ec.generate.lensflare", &[], Image::new(64, 64));
|
||||
let c = o.img.get(16, 16)[0];
|
||||
assert!(c > o.img.get(60, 4)[0]);
|
||||
assert!(c > 0.5);
|
||||
}
|
||||
|
||||
fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
|
||||
let mut v: Vec<(&str, Value)> =
|
||||
vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
|
||||
v.extend_from_slice(set);
|
||||
crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_distance_of_a_square() {
|
||||
let (w, h) = (20, 20);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
|
||||
let d = crate::util::signed_distance(&inside, w, h);
|
||||
assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
|
||||
assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
|
||||
assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_mask_contours_profile_reflection_and_birth() {
|
||||
// Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
|
||||
let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
|
||||
let masks = [sq];
|
||||
let env = crate::EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
|
||||
assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
|
||||
assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
|
||||
// Rounded corner: diagonal distance 10 from (37, 37).
|
||||
assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
|
||||
// No mask chosen: nothing drawn.
|
||||
assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
|
||||
// Image Contours: the bright square of a source layer.
|
||||
struct Src;
|
||||
impl crate::EffectHost for Src {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let mut img = Image::new(64, 64);
|
||||
for y in 27..37 {
|
||||
for x in 27..37 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
// Colour animates from red (born at 0) to green (born later).
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
p.values.insert("waveMotion/frequency".into(), num(1.0));
|
||||
p.values.insert("waveMotion/expansion".into(), num(20.0));
|
||||
p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
|
||||
let ic = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
|
||||
// Source Center off the square shifts the contour onto the producer point accordingly.
|
||||
let sh = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
|
||||
// Profile: Taper Both thins the stroke edges; Square is solid across.
|
||||
let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
|
||||
let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
|
||||
let mut tap = wide.to_vec();
|
||||
tap.push(("waveStroke/profile", Value::Enum(3)));
|
||||
let tap = rw(&tap, 0.2, crate::EffectEnv::default());
|
||||
assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
|
||||
assert!(tap.get(52, 32)[3] > 0.8);
|
||||
// Reflection: a fast wave bounces back off the right edge.
|
||||
let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
|
||||
let gone = rw(&fast, 0.3, crate::EffectEnv::default());
|
||||
let mut rf = fast.to_vec();
|
||||
rf.push(("waveMotion/reflection", Value::Bool(true)));
|
||||
let back = rw(&rf, 0.3, crate::EffectEnv::default());
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
|
||||
// Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
|
||||
let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
|
||||
let (old, new) = (both.get(55, 32), both.get(35, 32));
|
||||
assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
|
||||
assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_hq_variants_antialias() {
|
||||
let s = crate::find("ec.generate.cellpattern").unwrap();
|
||||
let r = |pat: u32| {
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("cellPattern".into(), Value::Enum(pat));
|
||||
params.values.insert("size".into(), num(12.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
|
||||
};
|
||||
let distinct = |img: &Image| {
|
||||
let mut v: Vec<u32> = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v.len()
|
||||
};
|
||||
let (std, hq) = (r(4), r(9));
|
||||
assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
|
||||
assert_ne!(std, hq);
|
||||
assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
|
||||
}
|
||||
+20
-2424
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,314 @@
|
||||
use super::raster::{Seg, mask_px, over, poly_segs, raster_segs};
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul};
|
||||
|
||||
// ---------------------------------------------------------------- Audio Spectrum / Waveform
|
||||
|
||||
/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two).
|
||||
pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) {
|
||||
let n = re.len();
|
||||
if n < 2 {
|
||||
return;
|
||||
}
|
||||
let mut j = 0;
|
||||
for i in 1..n {
|
||||
let mut bit = n >> 1;
|
||||
while j & bit != 0 {
|
||||
j ^= bit;
|
||||
bit >>= 1;
|
||||
}
|
||||
j |= bit;
|
||||
if i < j {
|
||||
re.swap(i, j);
|
||||
im.swap(i, j);
|
||||
}
|
||||
}
|
||||
let mut len = 2;
|
||||
while len <= n {
|
||||
let ang = -2.0 * PI / len as f64;
|
||||
let (ws, wc) = ang.sin_cos();
|
||||
for start in (0..n).step_by(len) {
|
||||
let (mut cr, mut ci) = (1.0, 0.0);
|
||||
for k in 0..len / 2 {
|
||||
let (a, bb) = (start + k, start + k + len / 2);
|
||||
let tr = re[bb] * cr - im[bb] * ci;
|
||||
let ti = re[bb] * ci + im[bb] * cr;
|
||||
re[bb] = re[a] - tr;
|
||||
im[bb] = im[a] - ti;
|
||||
re[a] += tr;
|
||||
im[a] += ti;
|
||||
let ncr = cr * wc - ci * ws;
|
||||
ci = cr * ws + ci * wc;
|
||||
cr = ncr;
|
||||
}
|
||||
}
|
||||
len <<= 1;
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_RATE: u32 = 48_000;
|
||||
|
||||
/// Fetch mono (or one channel) samples of the audio layer around the current time.
|
||||
fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option<Vec<f64>> {
|
||||
let id = ctx.params.get("audioLayer")?.as_layer()?;
|
||||
let host = ctx.env.host?;
|
||||
let dur = (dur_ms / 1000.0).max(0.001);
|
||||
let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16);
|
||||
let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5;
|
||||
let s = host.audio(id, start, frames, AUDIO_RATE)?;
|
||||
Some(
|
||||
(0..frames)
|
||||
.map(|i| {
|
||||
let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64);
|
||||
match channel {
|
||||
1 => l,
|
||||
2 => r,
|
||||
_ => (l + r) * 0.5,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at
|
||||
/// parameter u∈[0,1] → (point, unit normal).
|
||||
struct Baseline {
|
||||
pts: Vec<[f64; 2]>,
|
||||
closed: bool,
|
||||
len: f64,
|
||||
polar: bool,
|
||||
center: [f64; 2],
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
impl Baseline {
|
||||
fn new(ctx: &EffectCtx, b: &Buf) -> Baseline {
|
||||
let pr = ctx.params;
|
||||
let polar = pr.b("usePolarPath");
|
||||
let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) {
|
||||
Some((p, c, _)) if !polar => (p, c),
|
||||
_ => {
|
||||
let s = b.to_px(pr.v2("startPoint"));
|
||||
let e = b.to_px(pr.v2("endPoint"));
|
||||
(vec![[s.0, s.1], [e.0, e.1]], false)
|
||||
}
|
||||
};
|
||||
let len = poly_length(&pts, closed);
|
||||
let center = pts[0];
|
||||
let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 };
|
||||
Baseline { pts, closed, len, polar, center, radius }
|
||||
}
|
||||
fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) {
|
||||
if self.polar {
|
||||
let a = u * 2.0 * PI - PI * 0.5;
|
||||
let (s, c) = a.sin_cos();
|
||||
return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]);
|
||||
}
|
||||
let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len);
|
||||
(q, [d[1], -d[0]])
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw line/dot marks with core (inside colour) and soft halo (outside colour).
|
||||
fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let core = raster_segs(w, h, segs_core, 1.0);
|
||||
let halo_segs: Vec<Seg> = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect();
|
||||
let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() };
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, hl) = (core.data[i], halo.data[i]);
|
||||
let a = c.max(hl);
|
||||
let base = if composite { *px } else { [0.0; 4] };
|
||||
if a <= 0.0 {
|
||||
*px = base;
|
||||
return;
|
||||
}
|
||||
let t = if a > 0.0 { c / a } else { 0.0 };
|
||||
let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t);
|
||||
*px = over(base, premul(col, a));
|
||||
});
|
||||
}
|
||||
|
||||
/// Audio Spectrum / Audio Waveform, shared with the GPU compositor (effectcraft-gpu `fx_gen2`):
|
||||
/// the marks (the audio analysis runs on the CPU) and how `draw_marks` colours them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MarksPlan {
|
||||
pub segs: Vec<Seg>,
|
||||
pub thickness: f64,
|
||||
pub softness: f64,
|
||||
pub inside: [f32; 4],
|
||||
pub outside: [f32; 4],
|
||||
pub composite: bool,
|
||||
}
|
||||
|
||||
/// [`MarksPlan`] of Audio Spectrum or Audio Waveform on `b`'s geometry (`None` = no audio:
|
||||
/// the layer passes through).
|
||||
pub fn marks_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
|
||||
match id {
|
||||
"ec.generate.audiospectrum" => spectrum_plan(ctx, b),
|
||||
"ec.generate.audiowaveform" => waveform_plan(ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn marks(ctx: &EffectCtx, segs: Vec<Seg>, thick: f64, soft: f64) -> MarksPlan {
|
||||
let pr = ctx.params;
|
||||
MarksPlan {
|
||||
segs,
|
||||
thickness: thick,
|
||||
softness: soft * 2.0,
|
||||
inside: pr.color("insideColor"),
|
||||
outside: pr.color("outsideColor"),
|
||||
composite: pr.b("compositeOnOriginal"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if let Some(m) = spectrum_plan(ctx, &b) {
|
||||
draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
pub(super) fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if let Some(m) = waveform_plan(ctx, &b) {
|
||||
draw_marks(&mut b, &m.segs, m.thickness, m.softness, m.inside, m.outside, m.composite);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn compute_fft_mags(samples: &[f64]) -> (usize, f64, Vec<f64>) {
|
||||
let m = samples.len();
|
||||
let n = m.next_power_of_two().max(64);
|
||||
let mut re = vec![0.0; n];
|
||||
let mut im = vec![0.0; n];
|
||||
let denom = (m.max(2) - 1) as f64;
|
||||
for (i, s) in samples.iter().enumerate() {
|
||||
let w = 0.5 - 0.5 * (2.0 * PI * i as f64 / denom).cos();
|
||||
if let Some(r) = re.get_mut(i) {
|
||||
*r = s * w;
|
||||
}
|
||||
}
|
||||
fft(&mut re, &mut im);
|
||||
let hz_per_bin = AUDIO_RATE as f64 / n as f64;
|
||||
let scale = 4.0 / m.max(1) as f64;
|
||||
let num_bins = n / 2 + 1;
|
||||
let mut mags = Vec::with_capacity(num_bins);
|
||||
for k in 0..num_bins {
|
||||
let r = re.get(k).copied().unwrap_or(0.0);
|
||||
let im_val = im.get(k).copied().unwrap_or(0.0);
|
||||
let mag = (r * r + im_val * im_val).sqrt() * scale;
|
||||
mags.push(mag);
|
||||
}
|
||||
(n, hz_per_bin, mags)
|
||||
}
|
||||
|
||||
fn spectrum_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
|
||||
let pr = ctx.params;
|
||||
let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0)?;
|
||||
let (_n, hz_per_bin, mags) = compute_fft_mags(&samples);
|
||||
|
||||
let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize;
|
||||
let f0 = pr.f("startFrequency").max(0.0);
|
||||
let f1 = pr.f("endFrequency").max(f0 + 1.0);
|
||||
let max_bin = mags.len().saturating_sub(1);
|
||||
|
||||
let get_band_mag = |fc: f64| -> f64 {
|
||||
if mags.is_empty() || hz_per_bin <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let k_float = (fc / hz_per_bin).clamp(0.0, max_bin as f64);
|
||||
let k0 = (k_float.floor() as usize).min(max_bin.saturating_sub(1));
|
||||
let k1 = (k0 + 1).min(max_bin);
|
||||
let frac = (k_float - k0 as f64).clamp(0.0, 1.0);
|
||||
let m0 = mags.get(k0).copied().unwrap_or(0.0);
|
||||
let m1 = mags.get(k1).copied().unwrap_or(0.0);
|
||||
m0 * (1.0 - frac) + m1 * frac
|
||||
};
|
||||
|
||||
let max_h = pr.f("maximumHeight") * b.scale;
|
||||
let thick = (pr.f("thickness") * b.scale).max(0.1);
|
||||
let soft = pr.f("softness") / 100.0;
|
||||
let disp = pr.e("displayOptions");
|
||||
let side = pr.e("sideOptions");
|
||||
let base = Baseline::new(ctx, b);
|
||||
let mut tops = Vec::with_capacity(bands);
|
||||
let mut segs = Vec::new();
|
||||
for i in 0..bands {
|
||||
let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 };
|
||||
let fc = f0 + (f1 - f0) * u;
|
||||
let v = get_band_mag(fc);
|
||||
let hgt = if (v * max_h).is_finite() { (v * max_h).clamp(0.0, max_h.max(0.0)) } else { 0.0 };
|
||||
let (q, nrm) = base.at(u);
|
||||
let (ua, ub) = match side {
|
||||
0 => (0.0, hgt),
|
||||
1 => (-hgt, 0.0),
|
||||
_ => (-hgt, hgt),
|
||||
};
|
||||
let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua];
|
||||
let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub];
|
||||
match disp {
|
||||
0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }),
|
||||
_ => tops.push((pa, pb)),
|
||||
}
|
||||
}
|
||||
if disp == 1 {
|
||||
for w2 in tops.windows(2) {
|
||||
segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 });
|
||||
if side == 2 {
|
||||
segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 });
|
||||
}
|
||||
}
|
||||
} else if disp == 2 {
|
||||
for &(pa, pb) in &tops {
|
||||
segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 });
|
||||
if side == 2 {
|
||||
segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(marks(ctx, segs, thick, soft))
|
||||
}
|
||||
|
||||
fn waveform_plan(ctx: &EffectCtx, b: &Buf) -> Option<MarksPlan> {
|
||||
let pr = ctx.params;
|
||||
let samples = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions"))?;
|
||||
let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize;
|
||||
let max_h = pr.f("maximumHeight") * b.scale;
|
||||
let thick = (pr.f("thickness") * b.scale).max(0.1);
|
||||
let soft = pr.f("softness") / 100.0;
|
||||
let disp = pr.e("displayOptions");
|
||||
let base = Baseline::new(ctx, b);
|
||||
let m = samples.len();
|
||||
let mut pts = Vec::with_capacity(count);
|
||||
let mut segs = Vec::new();
|
||||
for i in 0..count {
|
||||
let u = i as f64 / (count - 1) as f64;
|
||||
let a = ((u * (m - 1) as f64) as usize).min(m - 1);
|
||||
let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1);
|
||||
// Peak with sign of the window for a stable digital display.
|
||||
let (mut lo, mut hi) = (0.0f64, 0.0f64);
|
||||
for s in &samples[a..=bnd] {
|
||||
lo = lo.min(*s);
|
||||
hi = hi.max(*s);
|
||||
}
|
||||
let (q, nrm) = base.at(u);
|
||||
match disp {
|
||||
0 => segs.push(Seg {
|
||||
a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h],
|
||||
b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h],
|
||||
r: thick * 0.5,
|
||||
v: 1.0,
|
||||
}),
|
||||
_ => {
|
||||
let v = samples[a] * max_h;
|
||||
pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if disp == 1 {
|
||||
poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs);
|
||||
} else if disp == 2 {
|
||||
segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 }));
|
||||
}
|
||||
Some(marks(ctx, segs, thick, soft))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::{EffectSpec, ParamUi, Value, slider};
|
||||
|
||||
pub(super) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true }
|
||||
}
|
||||
|
||||
pub(super) fn pt(x: f64, y: f64) -> Value {
|
||||
Value::Vec2([x, y])
|
||||
}
|
||||
|
||||
pub(super) fn pct() -> ParamUi {
|
||||
slider(0.0, 100.0, 0.0, 100.0, 1)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use super::raster::over;
|
||||
use super::{
|
||||
Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1,
|
||||
hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul,
|
||||
};
|
||||
|
||||
pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let q = b.to_px(pr.v2("brushPosition"));
|
||||
let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5);
|
||||
let density = pr.f("density").max(0.0);
|
||||
let refl = pr.f("reflection").to_radians();
|
||||
let strength = (pr.f("strength") / 100.0) as f32;
|
||||
let glue_only = pr.e("style") == 1;
|
||||
let src = b.img.clone();
|
||||
let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53];
|
||||
let beads = (density.round() as usize).clamp(1, 32);
|
||||
let seed = ctx.seed;
|
||||
let centers: Vec<([f64; 2], f64)> = (0..beads)
|
||||
.map(|i| {
|
||||
let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI;
|
||||
let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 };
|
||||
([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64))
|
||||
})
|
||||
.collect();
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
// Height field: union of hemispheres.
|
||||
let mut best = (0.0f64, [0.0f64; 2], 1.0f64);
|
||||
for &(c, r) in ¢ers {
|
||||
let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r);
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 < 1.0 {
|
||||
let hgt = (1.0 - d2).sqrt();
|
||||
if hgt > best.0 {
|
||||
best = (hgt, [dx, dy], r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.0 <= 0.0 {
|
||||
if glue_only {
|
||||
*px = [0.0; 4];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let nrm = [best.1[0], best.1[1], best.0];
|
||||
let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0);
|
||||
let spec = ndl.powi(24) as f32;
|
||||
// Refraction: look through the bead.
|
||||
let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64);
|
||||
let edge = smoothstep(0.0, 0.15, best.0 as f32);
|
||||
let shade = 0.65 + 0.35 * ndl as f32;
|
||||
let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)];
|
||||
let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)];
|
||||
let base = if glue_only { [0.0; 4] } else { *px };
|
||||
*px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3));
|
||||
px[3] = px[3].clamp(0.0, 1.0);
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Paint Bucket
|
||||
|
||||
/// Paint Bucket's filled region (1 inside) on `b`: the flood fill from the Fill Point over the
|
||||
/// layer's pixels (shared with the GPU compositor, which reads the layer back for it).
|
||||
pub fn paint_bucket_region(ctx: &EffectCtx, b: &Buf) -> Plane {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let fp = b.to_px(pr.v2("fillPoint"));
|
||||
let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64);
|
||||
let sel = pr.e("fillSelector");
|
||||
let tol = (pr.f("tolerance") / 255.0) as f32;
|
||||
let key = |p: Px| -> [f32; 4] {
|
||||
let (c, a) = unpremul(p);
|
||||
match sel {
|
||||
1 => [c[0], c[1], c[2], 0.0],
|
||||
2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }],
|
||||
3 | 4 => [0.0, 0.0, 0.0, a],
|
||||
_ => p,
|
||||
}
|
||||
};
|
||||
let mut region = Plane::new(w, h);
|
||||
if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h {
|
||||
let seed = key(b.img.data[sy as usize * w + sx as usize]);
|
||||
let within = |i: usize| {
|
||||
let k = key(b.img.data[i]);
|
||||
if sel == 2 {
|
||||
return k[3] == seed[3];
|
||||
}
|
||||
(0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6)
|
||||
};
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut stack = vec![(sx as usize, sy as usize)];
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
let row = y * w;
|
||||
if seen[row + x] || !within(row + x) {
|
||||
continue;
|
||||
}
|
||||
let mut l = x;
|
||||
while l > 0 && !seen[row + l - 1] && within(row + l - 1) {
|
||||
l -= 1;
|
||||
}
|
||||
let mut r = x;
|
||||
while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) {
|
||||
r += 1;
|
||||
}
|
||||
for i in l..=r {
|
||||
seen[row + i] = true;
|
||||
region.data[row + i] = 1.0;
|
||||
if y > 0 && !seen[row - w + i] {
|
||||
stack.push((i, y - 1));
|
||||
}
|
||||
if y + 1 < h && !seen[row + w + i] {
|
||||
stack.push((i, y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
region
|
||||
}
|
||||
|
||||
pub(super) fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return b;
|
||||
}
|
||||
let region = paint_bucket_region(ctx, &b);
|
||||
if pr.b("viewThreshold") {
|
||||
b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]);
|
||||
return b;
|
||||
}
|
||||
let sc = b.scale;
|
||||
let mut cov = match pr.e("stroke") {
|
||||
1 => {
|
||||
let s = pr.f("featherSoftness") * sc * 0.5;
|
||||
if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() }
|
||||
}
|
||||
2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true),
|
||||
3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false),
|
||||
4 => {
|
||||
let r = pr.f("strokeWidth") * sc;
|
||||
let out = morph_frac(®ion, r * 0.5, true);
|
||||
let inn = morph_frac(®ion, r * 0.5, false);
|
||||
out.zip_map(&inn, |a, b| (a - b).max(0.0))
|
||||
}
|
||||
_ => gauss_plane(®ion, 0.5, 0.5),
|
||||
};
|
||||
if pr.e("stroke") == 0 {
|
||||
// Antialias: keep the hard interior, soften only the boundary.
|
||||
cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a });
|
||||
}
|
||||
let color = pr.color("color");
|
||||
let op = (pr.f("opacity") / 100.0) as f32;
|
||||
let mode = pr.e("blendingMode");
|
||||
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
|
||||
let k = (c * op * color[3]).clamp(0.0, 1.0);
|
||||
if k <= 0.0 {
|
||||
if mode == 9 {
|
||||
*px = [0.0; 4];
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (dc, da) = unpremul(*px);
|
||||
let fc = [color[0], color[1], color[2]];
|
||||
let mixed = match mode {
|
||||
1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]],
|
||||
2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]],
|
||||
3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])],
|
||||
4..=7 => {
|
||||
let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]);
|
||||
let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]);
|
||||
let (r, g, bl) = match mode {
|
||||
4 => hsl_to_rgb(fh, ds, dl),
|
||||
5 => hsl_to_rgb(dh, fs, dl),
|
||||
6 => hsl_to_rgb(fh, fs, dl),
|
||||
_ => hsl_to_rgb(dh, ds, fl),
|
||||
};
|
||||
[r, g, bl]
|
||||
}
|
||||
_ => fc,
|
||||
};
|
||||
*px = match mode {
|
||||
8 => over(premul(fc, k), *px),
|
||||
9 => premul(fc, k),
|
||||
0 => over(*px, premul(fc, k)),
|
||||
_ => {
|
||||
if da <= 1e-6 {
|
||||
return;
|
||||
}
|
||||
let c3 = crate::util::lerp3(dc, mixed, k);
|
||||
premul(c3, da)
|
||||
}
|
||||
};
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Eyedropper Fill
|
||||
|
||||
pub(super) fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let fill = eyedropper_color(ctx, &b);
|
||||
let keep_alpha = pr.b("maintainOriginalAlpha");
|
||||
let blend = (pr.f("blendWithOriginal") / 100.0) as f32;
|
||||
b.img.data.par_iter_mut().for_each(|px| {
|
||||
let a = if keep_alpha { px[3] } else { fill[3] };
|
||||
let f = premul([fill[0], fill[1], fill[2]], a);
|
||||
*px = crate::util::lerp4(f, *px, blend);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
/// Eyedropper Fill's sampled colour (straight RGB, alpha) from the layer's pixels (shared with
|
||||
/// the GPU compositor, which reads the layer back for it).
|
||||
pub fn eyedropper_color(ctx: &EffectCtx, b: &Buf) -> Px {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (b.img.width as i64, b.img.height as i64);
|
||||
let c = b.to_px(pr.v2("samplePoint"));
|
||||
let r = (pr.f("sampleRadius") * b.scale).max(0.0);
|
||||
let mode = pr.e("averagePixelColors");
|
||||
let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64);
|
||||
let ri = r.ceil() as i64;
|
||||
let mut acc = [0.0f64; 4];
|
||||
let mut n = 0.0f64;
|
||||
for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) {
|
||||
for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) {
|
||||
if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 {
|
||||
continue;
|
||||
}
|
||||
let px = b.img.data[(y * w + x) as usize];
|
||||
match mode {
|
||||
0 => {
|
||||
if px[3] > 1e-4 {
|
||||
let (s, _) = unpremul(px);
|
||||
for k in 0..3 {
|
||||
acc[k] += s[k] as f64;
|
||||
}
|
||||
acc[3] += 1.0;
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let (s, _) = unpremul(px);
|
||||
for k in 0..3 {
|
||||
acc[k] += s[k] as f64;
|
||||
}
|
||||
acc[3] += 1.0;
|
||||
n += 1.0;
|
||||
}
|
||||
2 => {
|
||||
for k in 0..3 {
|
||||
acc[k] += px[k] as f64;
|
||||
}
|
||||
acc[3] += 1.0;
|
||||
n += 1.0;
|
||||
}
|
||||
_ => {
|
||||
for k in 0..4 {
|
||||
acc[k] += px[k] as f64;
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n > 0.0 {
|
||||
if mode == 3 {
|
||||
let a = (acc[3] / n) as f32;
|
||||
let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] };
|
||||
[s[0], s[1], s[2], a]
|
||||
} else {
|
||||
[(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0]
|
||||
}
|
||||
} else {
|
||||
[0.0, 0.0, 0.0, 0.0]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Threads
|
||||
|
||||
pub(super) fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let sc = b.scale;
|
||||
let tw = (pr.f("width") * sc).max(1.0);
|
||||
let th = (pr.f("height") * sc).max(1.0);
|
||||
let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64;
|
||||
let ang = pr.f("direction").to_radians();
|
||||
let (sn, cs) = ang.sin_cos();
|
||||
let c = b.to_px(pr.v2("center"));
|
||||
let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0);
|
||||
let shadow = (pr.f("shadowing") / 100.0) as f32;
|
||||
let texture = (pr.f("texture") / 100.0) as f32;
|
||||
let src = b.img.clone();
|
||||
let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs);
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1);
|
||||
let u = dx * cs + dy * sn;
|
||||
let v = -dx * sn + dy * cs;
|
||||
let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64);
|
||||
let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64);
|
||||
// Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the
|
||||
// band |fu-0.5| < coverage/2.
|
||||
let in_h = (fv - 0.5).abs() < coverage * 0.5;
|
||||
let in_v = (fu - 0.5).abs() < coverage * 0.5;
|
||||
// Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps.
|
||||
let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps;
|
||||
let which = match (in_h, in_v) {
|
||||
(true, true) => Some(h_top),
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let Some(horizontal) = which else {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
};
|
||||
// Thread colour: the source sampled along the thread centre line.
|
||||
let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) };
|
||||
let s = src.sample_bilinear_clamped(sx, sy);
|
||||
// Round profile across the thread + darkening where it dives under its neighbour.
|
||||
let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) };
|
||||
let along = if horizontal { fu } else { fv };
|
||||
let profile = (1.0 - across * across).max(0.0).sqrt() as f32;
|
||||
let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0;
|
||||
let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 };
|
||||
let fib =
|
||||
if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 };
|
||||
let k = (shade * fib).max(0.0);
|
||||
*px = [s[0] * k, s[1] * k, s[2] * k, s[3]];
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use super::raster::over;
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb};
|
||||
|
||||
// ---------------------------------------------------------------- Fractal
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FracSet {
|
||||
/// Mandelbrot iterations start at the Julia centre (the "Over Julia" set choices).
|
||||
over_julia: bool,
|
||||
julia: bool,
|
||||
inverse: bool,
|
||||
}
|
||||
|
||||
/// Smooth escape count, or `None` inside the set.
|
||||
fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option<f64> {
|
||||
let (mut x, mut y) = z0;
|
||||
let lim2 = limit * limit;
|
||||
for i in 0..max_iter {
|
||||
let (nx, ny) = match power {
|
||||
3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y),
|
||||
4 => {
|
||||
let (a, b) = (x * x - y * y, 2.0 * x * y);
|
||||
(a * a - b * b, 2.0 * a * b)
|
||||
}
|
||||
_ => (x * x - y * y, 2.0 * x * y),
|
||||
};
|
||||
x = nx + c.0;
|
||||
y = ny + c.1;
|
||||
let m2 = x * x + y * y;
|
||||
if m2 > lim2 {
|
||||
let lz = m2.ln() * 0.5;
|
||||
let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln();
|
||||
return Some((i as f64 + 1.0 - nu).max(0.0));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fractal Set Choice options.
|
||||
pub(crate) const FRACTAL_SETS: [&str; 6] =
|
||||
["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Julia", "Mandelbrot Inverse Over Julia", "Julia", "Julia Inverse"];
|
||||
|
||||
/// Fractal Palette options.
|
||||
pub(crate) const FRACTAL_PALETTES: [&str; 4] = ["Lightness Gradient", "Hue Wheel", "Black And White", "Solid Color"];
|
||||
|
||||
/// Fractal Oversample Method options.
|
||||
pub(crate) const FRACTAL_OVERSAMPLE: [&str; 2] = ["Edge Detect-Fast-May Miss Pixels", "Brute Force-Slow-Every Pixel"];
|
||||
|
||||
pub(super) fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let set_choice = pr.e("setChoice");
|
||||
let power = 2 + pr.e("equation").min(2);
|
||||
let mc = (pr.f("mandelbrot/mandelbrotX"), pr.f("mandelbrot/mandelbrotY"));
|
||||
let mmag = pr.f("mandelbrot/mandelbrotMagnification");
|
||||
let mlim = pr.f("mandelbrot/mandelbrotEscapeLimit").max(1.01);
|
||||
let jc = (pr.f("julia/juliaX"), pr.f("julia/juliaY"));
|
||||
let jmag = pr.f("julia/juliaMagnification");
|
||||
let jlim = pr.f("julia/juliaEscapeLimit").max(1.01);
|
||||
let post = (pr.f("postInversionOffset/postInversionX"), pr.f("postInversionOffset/postInversionY"));
|
||||
let overlay = pr.b("fractalColor/overlay");
|
||||
let transparency = pr.b("fractalColor/transparency");
|
||||
let transparent_inside = transparency || overlay;
|
||||
let palette = pr.e("fractalColor/palette");
|
||||
let hue = (pr.f("fractalColor/hue") / 360.0) as f32;
|
||||
let steps = pr.f("fractalColor/cycleSteps").max(1.0);
|
||||
let cyc_off = pr.f("fractalColor/cycleOffset") / 360.0;
|
||||
let edge = pr.b("fractalColor/edgeHighlight");
|
||||
let every = pr.e("highQualitySettings/samplingMethod") == 1;
|
||||
let factor = pr.f("highQualitySettings/samplingFactor").round().clamp(1.0, 9.0) as usize;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let lw = ctx.layer_size[0].max(1.0);
|
||||
let lh = ctx.layer_size[1].max(1.0);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
let (off_x, off_y) = (b.offset[0], b.offset[1]);
|
||||
// FRACTAL_SETS order.
|
||||
let set = |julia, inverse, over_julia| FracSet { over_julia, julia, inverse };
|
||||
let sets: (FracSet, Option<FracSet>) = match set_choice {
|
||||
1 => (set(false, true, false), None),
|
||||
2 => (set(false, false, true), None),
|
||||
3 => (set(false, true, true), None),
|
||||
4 => (set(true, false, false), None),
|
||||
5 => (set(true, true, false), None),
|
||||
_ => (set(false, false, false), None),
|
||||
};
|
||||
let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32;
|
||||
let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option<f64> {
|
||||
let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) };
|
||||
let span = 3.5 / 2f64.powf(mag);
|
||||
let mut u = (lx - lw * 0.5) / lw * span + center.0;
|
||||
let mut v = (ly - lh * 0.5) / lw * span + center.1;
|
||||
if s.inverse {
|
||||
let d = (u * u + v * v).max(1e-12);
|
||||
u = u / d + post.0;
|
||||
v = -v / d + post.1;
|
||||
}
|
||||
if s.julia {
|
||||
escape((u, v), mc, power, lim, iter_for(mag))
|
||||
} else {
|
||||
let z0 = if s.over_julia { jc } else { (0.0, 0.0) };
|
||||
escape(z0, (u, v), power, lim, iter_for(mag))
|
||||
}
|
||||
};
|
||||
let eval = |lx: f64, ly: f64| -> Option<f64> {
|
||||
let a = eval_set(sets.0, lx, ly);
|
||||
match (a, sets.1) {
|
||||
(None, Some(s2)) => eval_set(s2, lx, ly),
|
||||
_ => a,
|
||||
}
|
||||
};
|
||||
let shade = |n: Option<f64>| -> Px {
|
||||
// Solid Color: only the inside of the set is drawn (Transparency flips the sides).
|
||||
if palette == 3 {
|
||||
let (r, g, bl) = hsl_to_rgb(hue, 1.0, 0.5);
|
||||
return if n.is_none() != transparency { [r, g, bl, 1.0] } else { [0.0; 4] };
|
||||
}
|
||||
let Some(n) = n else {
|
||||
return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] };
|
||||
};
|
||||
let u = n / steps + cyc_off;
|
||||
let band = (u.rem_euclid(1.0)) as f32;
|
||||
let (r, g, bl) = match palette {
|
||||
// Hue Wheel: the full hue circle at full saturation and brightness.
|
||||
1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5),
|
||||
// Black And White: alternating bands.
|
||||
2 => {
|
||||
let v = if (u.floor() as i64).rem_euclid(2) == 0 { 0.0 } else { 1.0 };
|
||||
(v, v, v)
|
||||
}
|
||||
// Lightness Gradient: black → hue → white, the hue moving 45° each cycle.
|
||||
_ => hsl_to_rgb((hue + u.floor() as f32 * 0.125).rem_euclid(1.0), 0.85, band.clamp(0.0, 1.0)),
|
||||
};
|
||||
let mut c = [r, g, bl];
|
||||
if edge && n.fract() < 0.12 {
|
||||
c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6];
|
||||
}
|
||||
[c[0], c[1], c[2], 1.0]
|
||||
};
|
||||
// First pass: one sample per pixel.
|
||||
let mut first: Vec<Option<f64>> = vec![None; w * h];
|
||||
first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
*v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv);
|
||||
}
|
||||
});
|
||||
let src = if overlay { Some(b.img.clone()) } else { None };
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let here = first[y * w + x];
|
||||
let needs_ss = factor > 1
|
||||
&& (every || {
|
||||
let mut diff = false;
|
||||
for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] {
|
||||
let (nx, ny) = (x as i64 + dx, y as i64 + dy);
|
||||
if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 {
|
||||
continue;
|
||||
}
|
||||
let o = first[ny as usize * w + nx as usize];
|
||||
diff |= match (here, o) {
|
||||
(Some(a), Some(c)) => (a - c).abs() > steps * 0.25,
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
diff
|
||||
});
|
||||
let c = if needs_ss {
|
||||
let mut acc = [0.0f32; 4];
|
||||
for sy in 0..factor {
|
||||
for sx in 0..factor {
|
||||
let fx = x as f64 + (sx as f64 + 0.5) / factor as f64;
|
||||
let fy = y as f64 + (sy as f64 + 0.5) / factor as f64;
|
||||
let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv));
|
||||
let a = s[3];
|
||||
for k in 0..3 {
|
||||
acc[k] += s[k] * a;
|
||||
}
|
||||
acc[3] += a;
|
||||
}
|
||||
}
|
||||
let n = (factor * factor) as f32;
|
||||
[acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n]
|
||||
} else {
|
||||
let s = shade(here);
|
||||
[s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]]
|
||||
};
|
||||
*px = match &src {
|
||||
Some(o) => over(o.data[y * w + x], c),
|
||||
None => c,
|
||||
};
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly};
|
||||
use super::{
|
||||
Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1,
|
||||
layer_or_self, luminance, poly_length, smoothstep, unpremul,
|
||||
};
|
||||
|
||||
/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal
|
||||
/// Original Image.
|
||||
pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) {
|
||||
img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
|
||||
let a = (c * opacity * color[3]).clamp(0.0, 1.0);
|
||||
let s = [color[0] * a, color[1] * a, color[2] * a, a];
|
||||
*px = match style {
|
||||
1 => s,
|
||||
2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a],
|
||||
_ => over(*px, s),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// Coverage of a plan-drawn generator: round-capped segments rasterised by `raster_segs`
|
||||
/// with `hardness`, or the union of convex polygons (`raster_convex`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Coverage {
|
||||
Segs { segs: Vec<Seg>, hardness: f64 },
|
||||
Convex(Vec<Vec<[f64; 2]>>),
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
fn raster(&self, w: usize, h: usize) -> Plane {
|
||||
match self {
|
||||
Coverage::Segs { segs, hardness } => raster_segs(w, h, segs, *hardness),
|
||||
Coverage::Convex(polys) => raster_convex(w, h, polys),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke, Scribble, Vegas and Write-on, shared with the GPU compositor (effectcraft-gpu
|
||||
/// `fx_gen2`): the coverage and how it is painted. `style` 0–2 as `paint` (On Original
|
||||
/// Image, On Transparent, Reveal Original Image), 3 = under the layer (Vegas' Composite Under).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PaintPlan {
|
||||
pub cov: Coverage,
|
||||
pub color: [f32; 4],
|
||||
pub opacity: f32,
|
||||
pub style: u32,
|
||||
}
|
||||
|
||||
/// Draw a [`PaintPlan`].
|
||||
fn draw_paint(b: &mut Buf, plan: &PaintPlan) {
|
||||
let cov = plan.cov.raster(b.img.width as usize, b.img.height as usize);
|
||||
if plan.style == 3 {
|
||||
let color = plan.color;
|
||||
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| {
|
||||
let a = c * color[3];
|
||||
*px = over([color[0] * a, color[1] * a, color[2] * a, a], *px);
|
||||
});
|
||||
} else {
|
||||
paint(&mut b.img, &cov, plan.color, plan.opacity, plan.style);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generators drawn from a plan that need the layer's own pixels (`b.img`) to build it.
|
||||
pub fn plan_reads_pixels(id: &str, ctx: &EffectCtx) -> bool {
|
||||
match id {
|
||||
// Image Contours read the layer itself when no (resolvable) Input Layer is chosen.
|
||||
"ec.generate.vegas" => ctx.params.e("stroke") == 0,
|
||||
"ec.generate.paintbucket" | "ec.generate.eyedropperfill" => true,
|
||||
"ec.generate.advancedlightning" => ctx.params.f("alphaObstacle").clamp(-10.0, 10.0) != 0.0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PaintPlan`] of Stroke, Scribble, Vegas or Write-on on `b`'s geometry (`b.img` is read
|
||||
/// only when [`plan_reads_pixels`]). `None`: Scribble without a usable mask (the layer is
|
||||
/// cleared when it composites On Transparent, else kept).
|
||||
pub fn paint_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<PaintPlan> {
|
||||
match id {
|
||||
"ec.generate.stroke" => Some(stroke_plan(ctx, b)),
|
||||
"ec.generate.scribble" => scribble_plan(ctx, b),
|
||||
"ec.generate.vegas" => Some(vegas_plan(ctx, b)),
|
||||
"ec.generate.writeon" => Some(write_on_plan(ctx, b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scribble's Composite as a `paint` style.
|
||||
pub fn scribble_style(ctx: &EffectCtx) -> u32 {
|
||||
match ctx.params.e("composite") {
|
||||
0 => 0,
|
||||
2 => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
match scribble_plan(ctx, &b) {
|
||||
Some(plan) => draw_paint(&mut b, &plan),
|
||||
None if scribble_style(ctx) == 1 => b.img = Image::new(b.img.width, b.img.height),
|
||||
None => {}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn scribble_plan(ctx: &EffectCtx, b: &Buf) -> Option<PaintPlan> {
|
||||
let pr = ctx.params;
|
||||
let which = pr.e("scribble");
|
||||
let polys: Vec<_> = if which == 0 { mask_px(ctx, b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, b) };
|
||||
let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect();
|
||||
let style = scribble_style(ctx);
|
||||
if polys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sc = b.scale;
|
||||
let fill = pr.e("fillType");
|
||||
let ew = pr.f("edgeOptions/edgeWidth").max(0.1) * sc;
|
||||
let ang = pr.f("angle").to_radians();
|
||||
let d = [ang.cos(), ang.sin()];
|
||||
let n = [-d[1], d[0]];
|
||||
let width = (pr.f("strokeWidth") * sc).max(0.1);
|
||||
let curv = pr.f("strokeOptions/curviness") / 100.0;
|
||||
let curv_var = pr.f("strokeOptions/curvinessVariation") / 100.0;
|
||||
let spacing = (pr.f("strokeOptions/spacing") * sc).max(0.75).max(width * 0.25);
|
||||
let spacing_var = pr.f("strokeOptions/spacingVariation") / 100.0;
|
||||
let overlap = pr.f("strokeOptions/pathOverlap") / 100.0;
|
||||
let overlap_var = pr.f("strokeOptions/pathOverlapVariation") / 100.0;
|
||||
let start = pr.f("start").clamp(0.0, 100.0) / 100.0;
|
||||
let end = pr.f("end").clamp(0.0, 100.0) / 100.0;
|
||||
let sequential = pr.b("fillPathsSequentially");
|
||||
let wiggle = pr.e("wiggleType");
|
||||
let wps = pr.f("wigglesPerSecond").max(0.0);
|
||||
let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed;
|
||||
let (step, frac) = match wiggle {
|
||||
0 => (0u32, 0.0f32),
|
||||
1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0),
|
||||
_ => {
|
||||
let u = (ctx.time * wps).max(0.0);
|
||||
(u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32))
|
||||
}
|
||||
};
|
||||
let rnd = |i: u32, k: u32| {
|
||||
let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0;
|
||||
if frac > 0.0 {
|
||||
let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0;
|
||||
a + (c - a) * frac
|
||||
} else {
|
||||
a
|
||||
}
|
||||
};
|
||||
let groups: Vec<Vec<(Vec<[f64; 2]>, bool, bool)>> =
|
||||
if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() };
|
||||
// Per group a zig-zag polyline (in buffer px).
|
||||
let mut lines: Vec<Vec<Vec<[f64; 2]>>> = Vec::new();
|
||||
let mut runs = Vec::new();
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
let mut nmin = f64::INFINITY;
|
||||
let mut nmax = f64::NEG_INFINITY;
|
||||
for (pts, _, _) in g {
|
||||
for q in pts {
|
||||
let v = q[0] * n[0] + q[1] * n[1];
|
||||
nmin = nmin.min(v);
|
||||
nmax = nmax.max(v);
|
||||
}
|
||||
}
|
||||
nmin -= ew;
|
||||
nmax += ew;
|
||||
// Chains of zig-zags: each run joins the nearest chain that ended on the previous
|
||||
// scan line (so separate regions get separate strokes).
|
||||
let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new();
|
||||
let mut s = nmin + spacing * 0.5;
|
||||
let mut li = 0u32;
|
||||
let reach = spacing * 3.0 + ew;
|
||||
while s < nmax && li < 20_000 {
|
||||
scan_runs(g, fill, ew, d, n, s, &mut runs);
|
||||
for (ri, &(a, bb)) in runs.iter().enumerate() {
|
||||
let k = (gi as u32) << 20 | ri as u32;
|
||||
let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45);
|
||||
let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45);
|
||||
let (a, bb) = (a - ov, bb + ov2);
|
||||
if bb <= a {
|
||||
continue;
|
||||
}
|
||||
let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s];
|
||||
let (pa, pb) = (at(a), at(bb));
|
||||
let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt();
|
||||
let best = chains
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| li > 0 && c.1 == li - 1)
|
||||
.map(|(i, c)| {
|
||||
let last = *c.0.last().unwrap_or(&pa);
|
||||
(i, dist(last, pa).min(dist(last, pb)))
|
||||
})
|
||||
.filter(|&(_, dd)| dd < reach)
|
||||
.min_by(|x, y| x.1.total_cmp(&y.1));
|
||||
match best {
|
||||
Some((ci, _)) => {
|
||||
let last = *chains[ci].0.last().unwrap_or(&pa);
|
||||
let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) };
|
||||
// Curved turn: an extra point pushed past the edge.
|
||||
let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0);
|
||||
let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing];
|
||||
let ch = &mut chains[ci];
|
||||
ch.0.extend([mid, p0, p1]);
|
||||
ch.1 = li;
|
||||
}
|
||||
None => chains.push((vec![pa, pb], li)),
|
||||
}
|
||||
}
|
||||
s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25);
|
||||
li += 1;
|
||||
}
|
||||
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
|
||||
}
|
||||
let r = width * 0.5;
|
||||
// Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own.
|
||||
let each = pr.e("startEndApplyTo") == 1;
|
||||
let mut parts: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
for group in &lines {
|
||||
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
let (g0, g1) = (total * start, total * end);
|
||||
let mut acc = 0.0;
|
||||
for (l, len) in group.iter().zip(lens) {
|
||||
let part =
|
||||
if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) };
|
||||
acc += len;
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
let cap = pr.e("edgeOptions/endCap");
|
||||
let join = pr.e("edgeOptions/join");
|
||||
let cov = if cap == 0 && join == 0 {
|
||||
let mut segs = Vec::new();
|
||||
for part in &parts {
|
||||
poly_segs(part, false, r, 1.0, &mut segs);
|
||||
}
|
||||
Coverage::Segs { segs, hardness: 0.9 }
|
||||
} else {
|
||||
Coverage::Convex(stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0)))
|
||||
};
|
||||
Some(PaintPlan { cov, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Stroke
|
||||
|
||||
pub(super) fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let plan = stroke_plan(ctx, &b);
|
||||
draw_paint(&mut b, &plan);
|
||||
b
|
||||
}
|
||||
|
||||
fn stroke_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
|
||||
let pr = ctx.params;
|
||||
let all = pr.b("allMasks");
|
||||
let polys: Vec<_> = if all { all_masks_px(ctx, b) } else { mask_px(ctx, b, pr.f("path").round() as usize).into_iter().collect() };
|
||||
let style = pr.e("paintStyle");
|
||||
let size = (pr.f("brushSize") * b.scale).max(0.1);
|
||||
let r = size * 0.5;
|
||||
let hard = pr.f("brushHardness") / 100.0;
|
||||
let start = pr.f("start").clamp(0.0, 100.0) / 100.0;
|
||||
let end = pr.f("end").clamp(0.0, 100.0) / 100.0;
|
||||
let spacing = pr.f("spacing") / 100.0;
|
||||
let sequential = pr.b("strokeSequentially");
|
||||
let lens: Vec<f64> = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
let mut segs = Vec::new();
|
||||
let mut acc = 0.0;
|
||||
for ((pts, closed, _), &len) in polys.iter().zip(&lens) {
|
||||
let (s0, s1) = if all && sequential {
|
||||
let (g0, g1) = (total * start, total * end);
|
||||
((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len))
|
||||
} else {
|
||||
(len * start, len * end)
|
||||
};
|
||||
acc += len;
|
||||
if s1 <= s0 {
|
||||
continue;
|
||||
}
|
||||
if spacing <= 0.5 {
|
||||
let part = trim_poly(pts, *closed, s0, s1);
|
||||
poly_segs(&part, false, r, 1.0, &mut segs);
|
||||
} else {
|
||||
let step = (size * spacing).max(0.5);
|
||||
let mut s = s0;
|
||||
while s <= s1 + 1e-9 && segs.len() < 200_000 {
|
||||
let (q, _) = crate::util::poly_point_at(pts, *closed, s);
|
||||
segs.push(Seg { a: q, b: q, r, v: 1.0 });
|
||||
s += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: (pr.f("opacity") / 100.0) as f32, style }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Vegas
|
||||
|
||||
/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag).
|
||||
pub(super) fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> {
|
||||
use std::collections::HashMap;
|
||||
let (w, h) = (pl.w, pl.h);
|
||||
if w < 2 || h < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1.
|
||||
let hk = |x: usize, y: usize| 2 * (y * w + x) as u64;
|
||||
let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1;
|
||||
let mut pos: HashMap<u64, [f64; 2]> = HashMap::new();
|
||||
let mut adj: HashMap<u64, Vec<u64>> = HashMap::new();
|
||||
let v = |x: usize, y: usize| pl.data[y * w + x];
|
||||
let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 };
|
||||
for y in 0..h - 1 {
|
||||
for x in 0..w - 1 {
|
||||
let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1));
|
||||
let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3;
|
||||
if idx == 0 || idx == 15 {
|
||||
continue;
|
||||
}
|
||||
// Edge points (pixel centres at +0.5).
|
||||
let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]);
|
||||
let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]);
|
||||
let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]);
|
||||
let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]);
|
||||
let pairs: &[(usize, usize)] = match idx {
|
||||
1 | 14 => &[(3, 0)],
|
||||
2 | 13 => &[(0, 1)],
|
||||
3 | 12 => &[(3, 1)],
|
||||
4 | 11 => &[(1, 2)],
|
||||
5 => &[(3, 0), (1, 2)],
|
||||
6 | 9 => &[(0, 2)],
|
||||
7 | 8 => &[(3, 2)],
|
||||
10 => &[(0, 1), (2, 3)],
|
||||
_ => &[],
|
||||
};
|
||||
let e = [top, right, bottom, left];
|
||||
for &(i, j) in pairs {
|
||||
pos.insert(e[i].0, e[i].1);
|
||||
pos.insert(e[j].0, e[j].1);
|
||||
adj.entry(e[i].0).or_default().push(e[j].0);
|
||||
adj.entry(e[j].0).or_default().push(e[i].0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut keys: Vec<u64> = adj.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
// Start open chains at endpoints (degree 1) first, then closed loops.
|
||||
keys.sort_by_key(|k| adj[k].len() != 1);
|
||||
let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for &k0 in &keys {
|
||||
for &first in &adj[&k0] {
|
||||
if used.contains(&(k0.min(first), k0.max(first))) {
|
||||
continue;
|
||||
}
|
||||
let mut chain = vec![pos[&k0]];
|
||||
let (mut prev, mut cur) = (k0, first);
|
||||
used.insert((prev.min(cur), prev.max(cur)));
|
||||
let mut closed = false;
|
||||
loop {
|
||||
chain.push(pos[&cur]);
|
||||
if cur == k0 {
|
||||
closed = true;
|
||||
chain.pop();
|
||||
break;
|
||||
}
|
||||
let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n))));
|
||||
match next {
|
||||
Some(n) => {
|
||||
used.insert((cur.min(n), cur.max(n)));
|
||||
prev = cur;
|
||||
cur = n;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if chain.len() >= 2 {
|
||||
out.push((chain, closed));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let plan = vegas_plan(ctx, &b);
|
||||
draw_paint(&mut b, &plan);
|
||||
b
|
||||
}
|
||||
|
||||
fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
|
||||
let pr = ctx.params;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let sc = b.scale;
|
||||
let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 {
|
||||
mask_px(ctx, b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default()
|
||||
} else {
|
||||
let src = layer_or_self(ctx, b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1);
|
||||
let ch = pr.e("channel");
|
||||
let inv = pr.b("invertInput");
|
||||
let mut pl = Plane::from_image(&src, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
let v = match ch {
|
||||
1 => c[0],
|
||||
2 => c[1],
|
||||
3 => c[2],
|
||||
4 => a,
|
||||
_ => luminance(c[0], c[1], c[2]) * a,
|
||||
};
|
||||
if inv { 1.0 - v } else { v }
|
||||
});
|
||||
let blur = pr.f("preBlur") * sc;
|
||||
if blur > 0.05 {
|
||||
pl = gauss_plane(&pl, blur, blur);
|
||||
}
|
||||
let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32);
|
||||
// Simplify by tolerance (drop points closer than tol × size).
|
||||
let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1;
|
||||
if tol > 0.0 {
|
||||
for (c, _) in cs.iter_mut() {
|
||||
let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len());
|
||||
for &q in c.iter() {
|
||||
if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) {
|
||||
keep.push(q);
|
||||
}
|
||||
}
|
||||
*c = keep;
|
||||
}
|
||||
}
|
||||
cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1)));
|
||||
if pr.e("renderMode") == 1 {
|
||||
let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1;
|
||||
cs.into_iter().skip(sel).take(1).collect()
|
||||
} else {
|
||||
cs
|
||||
}
|
||||
};
|
||||
let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize;
|
||||
// Shorter Contours Have Fewer Segments: segment count scales with contour length.
|
||||
let fewer = pr.e("stroke") == 0 && pr.e("shorterContoursHave") == 1;
|
||||
let longest = paths.iter().map(|(p, c)| poly_length(p, *c)).fold(0.0, f64::max);
|
||||
let seg_len = pr.f("length").clamp(0.0, 1.0);
|
||||
let bunched = pr.e("segmentDistribution") == 0;
|
||||
let rot = pr.f("rotation") / 360.0;
|
||||
let random_phase = pr.b("randomPhase");
|
||||
let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed;
|
||||
let width = (pr.f("width") * sc).max(0.1);
|
||||
let hard = pr.f("hardness").clamp(0.0, 1.0);
|
||||
let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99));
|
||||
let mut segs = Vec::new();
|
||||
for (ci, (pts, closed)) in paths.iter().enumerate() {
|
||||
let len = poly_length(pts, *closed);
|
||||
if len <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 };
|
||||
let nseg = if fewer && longest > 0.0 { ((nseg as f64 * len / longest).round() as usize).max(1) } else { nseg };
|
||||
let each = len / nseg as f64 * seg_len;
|
||||
for i in 0..nseg {
|
||||
let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 };
|
||||
let s0 = (base + phase).rem_euclid(1.0) * len;
|
||||
// Sub-sample the segment so opacity can ramp along it.
|
||||
let sub = 12usize;
|
||||
let mut prev: Option<[f64; 2]> = None;
|
||||
for k in 0..=sub {
|
||||
let f = k as f64 / sub as f64;
|
||||
let mut s = s0 + each * f;
|
||||
if *closed {
|
||||
s = s.rem_euclid(len);
|
||||
} else if s > len {
|
||||
break;
|
||||
}
|
||||
let (q, _) = crate::util::poly_point_at(pts, *closed, s);
|
||||
let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 };
|
||||
if let Some(pv) = prev
|
||||
&& ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0
|
||||
{
|
||||
segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) });
|
||||
}
|
||||
prev = Some(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
let style = match pr.e("blendMode") {
|
||||
1 => 0,
|
||||
2 => 3,
|
||||
3 => 2,
|
||||
_ => 1,
|
||||
};
|
||||
PaintPlan { cov: Coverage::Segs { segs, hardness: hard }, color: pr.color("color"), opacity: 1.0, style }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Write-on / Glue Gun
|
||||
|
||||
/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every
|
||||
/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length
|
||||
/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times;
|
||||
/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own
|
||||
/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per
|
||||
/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic.
|
||||
pub(super) fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let plan = write_on_plan(ctx, &b);
|
||||
draw_paint(&mut b, &plan);
|
||||
b
|
||||
}
|
||||
|
||||
/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined.
|
||||
const WRITE_ON_MAX_DABS: i64 = 4096;
|
||||
|
||||
fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan {
|
||||
let pr = ctx.params;
|
||||
let own_opacity = pr.e("paintTimeProperties") == 1;
|
||||
let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3);
|
||||
let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05);
|
||||
let dab = |p: &Params| {
|
||||
let q = b.to_px(p.v2("brushPosition"));
|
||||
([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 })
|
||||
};
|
||||
let mut segs = vec![];
|
||||
let t = ctx.time;
|
||||
let spacing = pr.f("brushSpacing").max(0.001);
|
||||
let life = pr.f("strokeLength").max(0.0);
|
||||
if let Some(host) = ctx.env.host
|
||||
&& t.is_finite()
|
||||
&& t >= 0.0
|
||||
{
|
||||
let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 };
|
||||
let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64;
|
||||
let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64;
|
||||
let n = k1.saturating_sub(k0).saturating_add(1);
|
||||
let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1);
|
||||
let mut prev: Option<[f64; 2]> = None;
|
||||
let mut k = k0;
|
||||
while k <= k1 {
|
||||
let tk = k as f64 * spacing;
|
||||
let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) };
|
||||
if let Some((q, r, v)) = at {
|
||||
// Dabs laid further apart than the brush spacing are joined into a line.
|
||||
let a = if stride > 1 { prev.unwrap_or(q) } else { q };
|
||||
segs.push(Seg { a, b: q, r, v });
|
||||
prev = Some(q);
|
||||
}
|
||||
k = k.saturating_add(stride);
|
||||
}
|
||||
}
|
||||
if segs.is_empty() {
|
||||
let (q, r, v) = dab(pr);
|
||||
segs.push(Seg { a: q, b: q, r, v });
|
||||
}
|
||||
PaintPlan {
|
||||
cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 },
|
||||
color: pr.color("color"),
|
||||
opacity: if own_opacity { 1.0 } else { opacity(pr) },
|
||||
style: pr.e("paintStyle"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep};
|
||||
|
||||
// ---------------------------------------------------------------- shared rasterisation
|
||||
|
||||
/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Seg {
|
||||
pub a: [f64; 2],
|
||||
pub b: [f64; 2],
|
||||
pub r: f64,
|
||||
pub v: f32,
|
||||
}
|
||||
|
||||
/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction
|
||||
/// of the radius that is fully opaque; the rest falls off smoothly.
|
||||
pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane {
|
||||
let mut out = Plane::new(w, h);
|
||||
if w == 0 || h == 0 || segs.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); h];
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let pad = s.r + 1.0;
|
||||
let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize;
|
||||
let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64);
|
||||
if y1 <= 0.0 || y0 >= h {
|
||||
continue;
|
||||
}
|
||||
for row in buckets.iter_mut().take(y1 as usize).skip(y0) {
|
||||
row.push(i as u32);
|
||||
}
|
||||
}
|
||||
let hard = hardness.clamp(0.0, 1.0);
|
||||
out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
let py = y as f64 + 0.5;
|
||||
for &i in &buckets[y] {
|
||||
let s = segs[i as usize];
|
||||
let pad = s.r + 1.0;
|
||||
let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize;
|
||||
let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w);
|
||||
let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let inner = s.r * hard - 0.5;
|
||||
let outer = s.r + 0.5;
|
||||
for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) {
|
||||
let px = x as f64 + 0.5;
|
||||
let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt();
|
||||
if d >= outer {
|
||||
continue;
|
||||
}
|
||||
let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v;
|
||||
if c > *o {
|
||||
*o = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Segments along a polyline (buffer px).
|
||||
pub fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec<Seg>) {
|
||||
let n = pts.len();
|
||||
if n == 1 {
|
||||
out.push(Seg { a: pts[0], b: pts[0], r, v });
|
||||
}
|
||||
if n < 2 {
|
||||
return;
|
||||
}
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v });
|
||||
}
|
||||
}
|
||||
|
||||
/// Portion `[s0, s1]` (arc length) of a polyline.
|
||||
pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> {
|
||||
let n = pts.len();
|
||||
let mut out = Vec::new();
|
||||
if n < 2 || s1 <= s0 {
|
||||
return out;
|
||||
}
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
let mut acc = 0.0;
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
|
||||
let (e0, e1) = (acc, acc + l);
|
||||
acc = e1;
|
||||
if e1 < s0 || e0 > s1 || l <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let at = |s: f64| {
|
||||
let t = ((s - e0) / l).clamp(0.0, 1.0);
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
};
|
||||
let p0 = at(s0.max(e0));
|
||||
if out.last() != Some(&p0) {
|
||||
out.push(p0);
|
||||
}
|
||||
out.push(at(s1.min(e1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(super) fn over(dst: Px, src: Px) -> Px {
|
||||
let k = 1.0 - src[3];
|
||||
[src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k]
|
||||
}
|
||||
|
||||
/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels.
|
||||
pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> {
|
||||
let m = ctx.env.masks.get(idx.checked_sub(1)?)?;
|
||||
if m.points.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted))
|
||||
}
|
||||
|
||||
/// All masks mapped to buffer pixels.
|
||||
pub(super) fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> {
|
||||
(1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect()
|
||||
}
|
||||
|
||||
/// Chaikin corner cutting (open polyline, endpoints kept).
|
||||
pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> {
|
||||
let mut cur = pts.to_vec();
|
||||
for _ in 0..iters {
|
||||
if cur.len() < 3 {
|
||||
return cur;
|
||||
}
|
||||
let mut nx = Vec::with_capacity(cur.len() * 2);
|
||||
nx.push(cur[0]);
|
||||
for w in cur.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]);
|
||||
nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]);
|
||||
}
|
||||
nx.push(*cur.last().unwrap_or(&[0.0; 2]));
|
||||
cur = nx;
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Scribble
|
||||
|
||||
/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`.
|
||||
pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) {
|
||||
out.clear();
|
||||
for (pts, _closed, inverted) in polys {
|
||||
let m = pts.len();
|
||||
if m < 2 {
|
||||
continue;
|
||||
}
|
||||
// Crossings: (t along d, |sin| of crossing angle).
|
||||
let mut xs: Vec<(f64, f64)> = Vec::new();
|
||||
for i in 0..m {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s);
|
||||
if (na > 0.0) == (nb > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let t = na / (na - nb);
|
||||
let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
|
||||
let el = (ex * ex + ey * ey).sqrt().max(1e-9);
|
||||
let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2);
|
||||
xs.push((q[0] * d[0] + q[1] * d[1], sin));
|
||||
}
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Signed area decides which side is "left".
|
||||
let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum();
|
||||
let left_is_inside = area < 0.0;
|
||||
let mode = match fill {
|
||||
4 => {
|
||||
if left_is_inside {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
if left_is_inside {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
f => f,
|
||||
};
|
||||
let mode = if *inverted && mode == 0 { 6 } else { mode };
|
||||
for (k, &(t, sin)) in xs.iter().enumerate() {
|
||||
let entering = k % 2 == 0;
|
||||
let l = ew / sin;
|
||||
match mode {
|
||||
0 if entering && k + 1 < xs.len() => {
|
||||
out.push((t, xs[k + 1].0));
|
||||
}
|
||||
1 => out.push((t - l * 0.5, t + l * 0.5)),
|
||||
2 => out.push(if entering { (t, t + l) } else { (t - l, t) }),
|
||||
3 => out.push(if entering { (t - l, t) } else { (t, t + l) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if mode == 6 {
|
||||
// Inverted Inside: outside of the polygon within the bounds.
|
||||
let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew;
|
||||
let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew;
|
||||
let mut cur = lo;
|
||||
for pair in xs.chunks(2) {
|
||||
out.push((cur, pair[0].0));
|
||||
cur = pair.get(1).map(|v| v.0).unwrap_or(hi);
|
||||
}
|
||||
out.push((cur, hi));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Merge overlaps.
|
||||
let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len());
|
||||
for &(a, b) in out.iter() {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& a <= last.1
|
||||
{
|
||||
last.1 = last.1.max(b);
|
||||
continue;
|
||||
}
|
||||
merged.push((a, b));
|
||||
}
|
||||
*out = merged;
|
||||
}
|
||||
|
||||
/// End Cap and Join options (Scribble's Edge Options).
|
||||
pub(super) const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"];
|
||||
|
||||
pub(super) const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"];
|
||||
|
||||
/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`:
|
||||
/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the
|
||||
/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends).
|
||||
pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec<Vec<[f64; 2]>> {
|
||||
let disc = |c: [f64; 2]| -> Vec<[f64; 2]> {
|
||||
(0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for l in lines {
|
||||
let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| {
|
||||
if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) {
|
||||
v.push(p);
|
||||
}
|
||||
v
|
||||
});
|
||||
if pts.len() < 2 {
|
||||
if let (Some(p), 0) = (pts.first(), cap) {
|
||||
out.push(disc(*p));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let dir = |a: [f64; 2], b: [f64; 2]| {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = dx.hypot(dy).max(1e-12);
|
||||
[dx / l, dy / l]
|
||||
};
|
||||
let n = pts.len();
|
||||
for k in 0..n - 1 {
|
||||
let (mut a, mut b) = (pts[k], pts[k + 1]);
|
||||
let d = dir(a, b);
|
||||
// Projecting caps extend the end segments by the half width.
|
||||
if cap == 2 && k == 0 {
|
||||
a = [a[0] - d[0] * r, a[1] - d[1] * r];
|
||||
}
|
||||
if cap == 2 && k == n - 2 {
|
||||
b = [b[0] + d[0] * r, b[1] + d[1] * r];
|
||||
}
|
||||
let nn = [-d[1] * r, d[0] * r];
|
||||
out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]);
|
||||
}
|
||||
// Joins.
|
||||
for k in 1..n - 1 {
|
||||
let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1]));
|
||||
let cross = d0[0] * d1[1] - d0[1] * d1[0];
|
||||
if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 {
|
||||
continue;
|
||||
}
|
||||
if join == 0 {
|
||||
out.push(disc(p));
|
||||
continue;
|
||||
}
|
||||
// The outer side of the turn.
|
||||
let s = if cross > 0.0 { -1.0 } else { 1.0 };
|
||||
let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s];
|
||||
let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s];
|
||||
let bevel = vec![p, o0, o1];
|
||||
if join == 1 {
|
||||
// Miter point: where the two offset edges meet.
|
||||
let bis = [d0[0] - d1[0], d0[1] - d1[1]];
|
||||
let bl = bis[0].hypot(bis[1]);
|
||||
let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt();
|
||||
let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY };
|
||||
if bl > 1e-9 && len <= miter_limit * r {
|
||||
let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5];
|
||||
let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12);
|
||||
let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len];
|
||||
out.push(vec![p, o0, tip, o1]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bevel);
|
||||
}
|
||||
if cap == 0 {
|
||||
out.push(disc(pts[0]));
|
||||
out.push(disc(pts[n - 1]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid.
|
||||
pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane {
|
||||
let mut out = Plane::new(w, h);
|
||||
let inside = |poly: &[[f64; 2]], x: f64, y: f64| {
|
||||
let n = poly.len();
|
||||
let mut sign = 0.0;
|
||||
for i in 0..n {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % n]);
|
||||
let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
|
||||
if c.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = c.signum();
|
||||
} else if c.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
};
|
||||
for poly in polys {
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in poly {
|
||||
x0 = x0.min(p[0]);
|
||||
y0 = y0.min(p[1]);
|
||||
x1 = x1.max(p[0]);
|
||||
y1 = y1.max(p[1]);
|
||||
}
|
||||
let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w));
|
||||
let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h));
|
||||
for y in ya..yb {
|
||||
for x in xa..xb {
|
||||
let mut hit = 0;
|
||||
for sy in 0..4 {
|
||||
for sx in 0..4 {
|
||||
if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) {
|
||||
hit += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = &mut out.data[y * w + x];
|
||||
*v = v.max(hit as f32 / 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Loaded 100 of 1033 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user