diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -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]"
diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 000000000..2a4cd2cb9
--- /dev/null
+++ b/.config/nextest.toml
@@ -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"
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..2c4b8cb1a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -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)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..2095acad7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index f8ac8a300..8967f9431 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -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:
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 1353830f8..b903f82ea 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -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')
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 808e1628d..3cfbdc8b0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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:
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..8f1aa5ad5
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -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
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index a074e6dbe..4499e3fdc 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -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@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: effectcraft
app-dir: EffectCraft
+ install-relative-path: Programs/ArtCraft/EffectCraft
+ install-scope: perUser
diff --git a/AGENTS.md b/AGENTS.md
index aad29c94a..24abee6a1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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).
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index be99cdce1..77a9cc432 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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 `.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`) |
diff --git a/CLAUDE.md b/CLAUDE.md
index a7c47fe3f..21f2df36b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 9db588720..52812471c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,66 +4,60 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"uuid",
]
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
- "phf 0.13.1",
+ "phf",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -71,9 +65,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -89,13 +83,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows 0.62.2",
"windows-core 0.62.2",
@@ -103,11 +97,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -214,14 +209,15 @@ dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -436,13 +432,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
-name = "bit-set"
-version = "0.8.0"
+name = "base64"
+version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
@@ -454,10 +447,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -465,6 +461,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -483,6 +489,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "block2"
version = "0.5.1"
@@ -498,7 +513,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -567,7 +582,7 @@ dependencies = [
"oneshot",
"pastey",
"portable-atomic",
- "rand 0.10.3",
+ "rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
@@ -607,7 +622,7 @@ dependencies = [
"hashbrown 0.17.1",
"indexmap",
"once_cell",
- "phf 0.14.0",
+ "phf",
"rustc-hash 2.1.3",
"static_assertions",
]
@@ -658,6 +673,30 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -799,8 +838,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
- "cpufeatures",
- "rand_core 0.10.1",
+ "cpufeatures 0.3.1",
+ "rand_core",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
]
[[package]]
@@ -812,6 +860,15 @@ dependencies = [
"error-code",
]
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "codespan-reporting"
version = "0.13.1"
@@ -867,6 +924,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -880,7 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@@ -893,7 +960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
"libc",
]
@@ -959,6 +1026,15 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -971,24 +1047,100 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-fonts-build"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-i18n"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "fixed_decimal",
+ "icu_decimal",
+ "icu_locale_core",
+ "icu_locale_fallback",
+ "icu_normalizer",
+ "icu_plurals",
+ "icu_properties",
+ "icu_provider",
+ "icu_provider_adapters",
+ "icu_provider_blob",
+ "log",
+ "objc2-foundation 0.3.2",
+ "sys-locale",
+ "unicode-bidi",
+ "windows-registry",
+ "writeable",
+]
[[package]]
name = "craft-pdf"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-syntax",
"thiserror 2.0.21",
]
+[[package]]
+name = "craft-quit"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "dispatch2",
+ "egui",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
+ "craft-fonts-build",
+ "craft-i18n",
"egui",
+ "epaint",
+ "js-sys",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
+ "skrifa",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "craft-update"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip",
]
[[package]]
@@ -1040,6 +1192,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1072,6 +1234,16 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1087,7 +1259,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -1131,6 +1303,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
[[package]]
name = "dynify"
version = "0.1.2"
@@ -1153,9 +1331,8 @@ dependencies = [
[[package]]
name = "ecolor"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1163,9 +1340,10 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"cpal",
+ "craft-update",
"effectcraft-engine",
"effectcraft-host",
"effectcraft-ui-egui",
@@ -1174,10 +1352,10 @@ dependencies = [
"fmv-macos-events",
"log",
"muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
- "pollster 0.4.0",
+ "pollster 1.0.1",
"rfd",
"serde",
"serde_json",
@@ -1188,7 +1366,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1378,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1401,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"proptest",
"serde",
@@ -1231,8 +1409,9 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
+ "chrono",
"effectcraft-color",
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1248,8 +1427,9 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
+ "chrono",
"craft-docpath",
"effectcraft-color",
"effectcraft-effects",
@@ -1282,7 +1462,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1492,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1509,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1517,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1351,7 +1531,7 @@ dependencies = [
"effectcraft-time",
"js-sys",
"log",
- "pollster 0.4.0",
+ "pollster 1.0.1",
"wasm-bindgen",
"web-time",
"wgpu",
@@ -1359,14 +1539,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1571,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1590,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1602,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1620,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,11 +1649,11 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-geom",
"image",
- "miniz_oxide 0.8.9",
+ "miniz_oxide 0.9.1",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -1481,14 +1661,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1680,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1508,7 +1688,7 @@ dependencies = [
"effectcraft-text",
"image",
"kurbo",
- "miniz_oxide 0.8.9",
+ "miniz_oxide 0.9.1",
"skrifa",
"wasm-bindgen-test",
"zune-jpeg",
@@ -1516,7 +1696,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1708,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1721,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
- "miniz_oxide 0.8.9",
+ "miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1737,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1759,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1591,21 +1771,22 @@ dependencies = [
"effectcraft-render",
"effectcraft-time",
"serde_json",
+ "socket2",
"web-time",
]
[[package]]
name = "effectcraft-segment"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
- "miniz_oxide 0.8.9",
+ "miniz_oxide 0.9.1",
"ndarray",
"rayon",
]
[[package]]
name = "effectcraft-svg"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1798,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1810,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1818,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,9 +1830,12 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
+ "chrono",
+ "craft-quit",
"craft-ui",
+ "craft-update",
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",
@@ -1670,7 +1854,7 @@ dependencies = [
"image",
"kurbo",
"log",
- "pollster 0.4.0",
+ "pollster 1.0.1",
"rayon",
"serde",
"serde_json",
@@ -1681,11 +1865,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.10.0"
+version = "0.18.0"
[[package]]
name = "effectcraft-web"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1708,9 +1892,8 @@ dependencies = [
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1724,7 +1907,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1732,6 +1915,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
+ "send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1743,9 +1927,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1763,9 +1946,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1783,31 +1965,34 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1819,9 +2004,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"eframe",
"egui",
@@ -1843,9 +2027,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1893,9 +2076,8 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1911,16 +2093,17 @@ dependencies = [
"self_cell",
"skrifa",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -2049,6 +2232,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
[[package]]
name = "filmcraft-aac"
version = "0.1.1"
@@ -2310,6 +2503,17 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+[[package]]
+name = "fixed_decimal"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
+dependencies = [
+ "displaydoc",
+ "smallvec",
+ "writeable",
+]
+
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2333,7 +2537,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@@ -2469,6 +2673,16 @@ dependencies = [
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -2479,6 +2693,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -2501,7 +2726,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
- "rand_core 0.10.1",
+ "rand_core",
"wasm-bindgen",
]
@@ -2528,9 +2753,9 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash 0.2.0",
@@ -2568,7 +2793,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2713,8 +2938,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-ccitt",
]
@@ -2728,7 +2952,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
-source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-jbig2",
"hayro-jpeg2000",
@@ -2756,6 +2980,22 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "icu_collections"
version = "2.3.0"
@@ -2770,6 +3010,23 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_decimal"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_locale_core",
+ "icu_pattern",
+ "icu_plurals",
+ "icu_provider",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
[[package]]
name = "icu_locale_core"
version = "2.3.0"
@@ -2778,11 +3035,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
+[[package]]
+name = "icu_locale_fallback"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
+
[[package]]
name = "icu_normalizer"
version = "2.3.0"
@@ -2795,6 +3073,7 @@ dependencies = [
"icu_provider",
"smallvec",
"utf16_iter",
+ "utf8_iter",
"write16",
"zerovec",
]
@@ -2805,6 +3084,32 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+[[package]]
+name = "icu_pattern"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
+dependencies = [
+ "displaydoc",
+ "either",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_plurals"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_provider",
+ "serde",
+ "zerovec",
+]
+
[[package]]
name = "icu_properties"
version = "2.3.0"
@@ -2834,6 +3139,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "postcard",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2841,6 +3149,30 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_provider_adapters"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
+dependencies = [
+ "icu_locale_fallback",
+ "icu_provider",
+]
+
+[[package]]
+name = "icu_provider_blob"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
+dependencies = [
+ "icu_provider",
+ "postcard",
+ "serde",
+ "writeable",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -3047,12 +3379,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
]
[[package]]
@@ -3128,9 +3460,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
+checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo",
@@ -3156,6 +3488,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "litrs"
@@ -3180,9 +3515,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
-version = "0.11.6"
+version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
+checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
[[package]]
name = "mach2"
@@ -3269,18 +3604,17 @@ dependencies = [
[[package]]
name = "muda"
-version = "0.21.0"
+version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
+checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
- "once_cell",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
@@ -3375,12 +3709,33 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3496,9 +3851,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -3527,7 +3882,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3541,7 +3896,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.13.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
@@ -3579,7 +3934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-audio-types",
"objc2-core-foundation",
]
@@ -3591,7 +3946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3614,7 +3969,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3625,7 +3980,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -3681,7 +4036,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3692,7 +4047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3728,7 +4083,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -3752,7 +4107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3797,7 +4152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3844,6 +4199,12 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3873,6 +4234,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.45.0",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -3934,14 +4305,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "phf"
-version = "0.13.1"
+name = "petgraph"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
- "phf_macros 0.13.1",
- "phf_shared 0.13.1",
- "serde",
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
]
[[package]]
@@ -3950,18 +4321,9 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
- "phf_macros 0.14.0",
- "phf_shared 0.14.0",
-]
-
-[[package]]
-name = "phf_generator"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
-dependencies = [
- "fastrand",
- "phf_shared 0.13.1",
+ "phf_macros",
+ "phf_shared",
+ "serde",
]
[[package]]
@@ -3971,20 +4333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
- "phf_shared 0.14.0",
-]
-
-[[package]]
-name = "phf_macros"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
-dependencies = [
- "phf_generator 0.13.1",
- "phf_shared 0.13.1",
- "proc-macro2",
- "quote",
- "syn 2.0.119",
+ "phf_shared",
]
[[package]]
@@ -3993,22 +4342,13 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
- "phf_generator 0.14.0",
- "phf_shared 0.14.0",
+ "phf_generator",
+ "phf_shared",
"proc-macro2",
"quote",
"syn 2.0.119",
]
-[[package]]
-name = "phf_shared"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
-dependencies = [
- "siphasher",
-]
-
[[package]]
name = "phf_shared"
version = "0.14.0"
@@ -4067,6 +4407,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "plist"
+version = "1.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
+dependencies = [
+ "base64",
+ "indexmap",
+ "quick-xml 0.42.0",
+ "serde",
+ "time",
+]
+
[[package]]
name = "png"
version = "0.18.1"
@@ -4130,12 +4483,24 @@ dependencies = [
"portable-atomic",
]
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "serde",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
@@ -4145,15 +4510,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -4186,16 +4542,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -4253,6 +4610,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -4274,16 +4640,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -4292,26 +4648,7 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
- "rand_core 0.10.1",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
+ "rand_core",
]
[[package]]
@@ -4322,11 +4659,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core",
]
[[package]]
@@ -4356,7 +4693,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -4464,7 +4801,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -4480,6 +4817,20 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "roxmltree"
version = "0.21.1"
@@ -4536,6 +4887,80 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni 0.22.4",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -4569,6 +4994,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "scoped-tls"
version = "1.0.1"
@@ -4581,6 +5015,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4592,6 +5049,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -4625,9 +5092,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -4656,6 +5123,17 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -4815,6 +5293,16 @@ dependencies = [
"serde",
]
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "spin"
version = "0.9.9"
@@ -4890,6 +5378,12 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "symphonia"
version = "0.5.5"
@@ -5087,7 +5581,10 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
+ "js-sys",
"libc",
+ "wasm-bindgen",
+ "web-sys",
]
[[package]]
@@ -5102,6 +5599,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5249,6 +5756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -5350,6 +5858,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "type-map"
version = "0.5.1"
@@ -5359,6 +5878,18 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5427,6 +5958,41 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
+dependencies = [
+ "base64",
+ "flate2",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "ureq-proto",
+ "utf8-zero",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
+dependencies = [
+ "base64",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -5445,6 +6011,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5464,9 +6036,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -5479,9 +6051,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -5489,6 +6061,16 @@ dependencies = [
"vello_common",
]
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "version_check"
version = "0.9.5"
@@ -5514,6 +6096,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -5830,7 +6418,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml",
+ "quick-xml 0.41.0",
"quote",
]
@@ -5875,13 +6463,22 @@ dependencies = [
"jni 0.22.4",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
"web-sys",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -6016,7 +6613,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -6205,6 +6802,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings",
+]
+
[[package]]
name = "windows-result"
version = "0.1.2"
@@ -6259,15 +6867,6 @@ dependencies = [
"windows-targets 0.52.6",
]
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
-]
-
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -6301,30 +6900,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
+ "windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
-]
-
[[package]]
name = "windows-threading"
version = "0.2.1"
@@ -6346,12 +6928,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -6364,12 +6940,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -6382,24 +6952,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -6412,12 +6970,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -6430,12 +6982,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -6448,12 +6994,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -6466,17 +7006,10 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -6487,7 +7020,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
- "core-foundation",
+ "core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -6533,21 +7066,55 @@ dependencies = [
]
[[package]]
-name = "winresource"
-version = "0.1.31"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
]
+[[package]]
+name = "winsafe"
+version = "0.0.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "write16"
version = "1.0.0"
@@ -6559,6 +7126,9 @@ name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+dependencies = [
+ "either",
+]
[[package]]
name = "x11-dl"
@@ -6625,9 +7195,11 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.10.0"
+version = "0.18.0"
dependencies = [
+ "chrono",
"serde_json",
+ "tempfile",
]
[[package]]
@@ -6800,6 +7372,12 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
[[package]]
name = "zerotrie"
version = "0.2.5"
@@ -6807,8 +7385,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "litemap",
+ "serde_core",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
@@ -6817,6 +7398,7 @@ version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
@@ -6833,6 +7415,23 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "zip"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -6845,6 +7444,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -6909,3 +7520,23 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_extras"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index 634bda76e..aa917fc63 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,15 +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-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
+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" }
@@ -52,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"
@@ -100,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).
@@ -129,17 +136,22 @@ 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"
# Pixel hot loops: keep debug builds interactive.
[profile.dev.package.effectcraft-raster]
@@ -211,4 +223,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" }
diff --git a/NOTICE b/NOTICE
index 43bd2e2a9..5cf18a553 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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-.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.
diff --git a/README.md b/README.md
index 914a40907..81d42360c 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,18 @@
+
+
+
+
EffectCraft
- Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.
+ Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.
- A free, open-source compositor in the spirit of After Effects: compositions, layers,
+ A free, open-source compositor: compositions, layers,
keyframes, 306 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.
@@ -24,6 +28,9 @@
+
+
+
@@ -64,13 +71,14 @@
## 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.
@@ -84,7 +92,7 @@ then to go further in a few places where it matters to us:
## 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 +110,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 +126,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,
+306 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 +142,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,7 +153,7 @@ 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
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
@@ -219,25 +227,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 306 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 +254,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 +275,13 @@ send us right now.
| arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--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--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 +381,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.
-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.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index e21039c02..f4c99d61f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
| Korean | none | 0% |
| Vietnamese | none | 0% |
-Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had
+Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had
native-speaker review.
## Upcoming
@@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
## Progress log
+- 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-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
@@ -131,6 +135,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
+- 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-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs
index 43e514048..6e5b8d6cc 100644
--- a/apps/effectcraft-cli/src/main.rs
+++ b/apps/effectcraft-cli/src/main.rs
@@ -6,6 +6,7 @@
//! effectcraft-cli exec [--params JSON] run one command
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
//! effectcraft-cli run [ …] run several commands in order
+//! effectcraft-cli run --args-file F the same, one argument per line of F
//! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get [--time S] read a property
//! effectcraft-cli set [--time S] [--expression E]
@@ -16,6 +17,8 @@
//! [--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 `
//! 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 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`)
run [ ...] run several commands in order
+ run --args-file F the same, one argument per line of F (no command-line length limit)
props [--flat] [--time S] a layer's property tree with paths
get [--time S] read a property
set [--time S] [--expression E]
@@ -106,6 +110,8 @@ const USAGE: &str = "usage: effectcraft-cli )
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 +150,7 @@ const VALUED: &[&str] = &[
"--expression",
"--depth",
"--eval",
+ "--args-file",
// render
"--template",
"--values",
@@ -189,6 +196,7 @@ const FLAGS: &[&str] = &[
"--small",
"--adv3d",
"--autosave",
+ "--progress",
];
struct Args {
@@ -510,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&with_saved(r, saved), json_out);
}
"run" => {
- if args.pos.is_empty() {
+ 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));
+ }
+ if pairs.is_empty() {
return usage_err("run needs [] pairs");
}
- let pairs = args.pos.clone();
let mut b = backend(args, true)?;
let mut results = vec![];
let mut i = 0;
@@ -772,8 +785,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!(
diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs
index f84303dd0..8cf5c124f 100644
--- a/apps/effectcraft-cli/src/template.rs
+++ b/apps/effectcraft-cli/src/template.rs
@@ -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) -> 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) -> 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, 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) -> 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) -> 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(())
diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs
similarity index 85%
rename from apps/effectcraft-cli/tests/cli.rs
rename to apps/effectcraft-cli/tests/it/cli.rs
index 30a0271b6..c7a905e58 100644
--- a/apps/effectcraft-cli/tests/cli.rs
+++ b/apps/effectcraft-cli/tests/it/cli.rs
@@ -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,29 @@ 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"));
+}
+
#[test]
fn render_frame_writes_png() {
let out = tmp("f.png");
@@ -593,3 +616,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 ` 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::().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}");
+}
diff --git a/apps/effectcraft-cli/tests/it/main.rs b/apps/effectcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..bca36ec78
--- /dev/null
+++ b/apps/effectcraft-cli/tests/it/main.rs
@@ -0,0 +1,9 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.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())
+}
diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs
similarity index 81%
rename from apps/effectcraft-cli/tests/qa_template.rs
rename to apps/effectcraft-cli/tests/it/qa_template.rs
index 2765b163e..ffbdb94e9 100644
--- a/apps/effectcraft-cli/tests/qa_template.rs
+++ b/apps/effectcraft-cli/tests/it/qa_template.rs
@@ -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);
+}
diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml
index 6c30f9a56..28ea2f369 100644
--- a/apps/effectcraft-web/Cargo.toml
+++ b/apps/effectcraft-web/Cargo.toml
@@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [
"HtmlElement",
"HtmlInputElement",
"Location",
+ "Navigator",
"Url",
"UrlSearchParams",
"Window",
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 3db6ad35c..9346acc29 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -19,7 +19,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 +57,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 +90,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 +116,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 +128,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 +227,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 +318,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 +335,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 +408,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 +447,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 +494,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 +544,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 +592,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 +644,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 +652,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 +706,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 +714,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();
diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs
index 1a6952408..169529e5d 100644
--- a/apps/effectcraft-web/src/app.rs
+++ b/apps/effectcraft-web/src/app.rs
@@ -86,27 +86,39 @@ pub struct WebApp {
}
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 = 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 = 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();
// 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 +126,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 +197,20 @@ 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::>());
+ 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()?;
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,7 +218,7 @@ 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();
browse::restore();
diskcache::init();
@@ -209,6 +229,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.
@@ -257,6 +278,7 @@ 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);
diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs
index ad7500fea..2fd680dcb 100644
--- a/apps/effectcraft-web/src/browse.rs
+++ b/apps/effectcraft-web/src/browse.rs
@@ -52,7 +52,7 @@ struct State {
static STATE: LazyLock> = 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.
diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs
index 3f3f3a99f..f6aac5aa4 100644
--- a/apps/effectcraft-web/src/files.rs
+++ b/apps/effectcraft-web/src/files.rs
@@ -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 {
+ 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 {
/// 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);
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index 523fd03c8..69089e308 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
})
}
+type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec), f64);
+
thread_local! {
/// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
- #[allow(clippy::type_complexity)]
- static CHECKS: RefCell), f64)>> = RefCell::new(Default::default());
+ static CHECKS: RefCell> = 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
}
diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs
index 0d59906f2..97d2e887f 100644
--- a/apps/effectcraft-web/src/lib.rs
+++ b/apps/effectcraft-web/src/lib.rs
@@ -6,17 +6,17 @@
//!
//! | 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 | `` 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.
diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
index b3a858def..8aafdac1c 100644
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -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,
}
/// 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,
/// Called after every change that needs flushing (the web host schedules a flush).
pub on_change: OnceLock,
/// Clock in milliseconds since the Unix epoch (tests pin it).
pub clock: OnceLock f64>,
+ /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
+ media_epoch: Arc,
+}
+
+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 {
+ 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> {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
@@ -446,6 +494,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).
diff --git a/apps/effectcraft-web/tests/smoke.mjs b/apps/effectcraft-web/tests/smoke.mjs
index 3a6c1337c..83091a72a 100644
--- a/apps/effectcraft-web/tests/smoke.mjs
+++ b/apps/effectcraft-web/tests/smoke.mjs
@@ -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);
diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs
new file mode 100644
index 000000000..0855c1674
--- /dev/null
+++ b/apps/effectcraft-web/tests/storage.mjs
@@ -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");
diff --git a/apps/effectcraft-web/tests/workers.mjs b/apps/effectcraft-web/tests/workers.mjs
index 15a77f1d5..58901885f 100644
--- a/apps/effectcraft-web/tests/workers.mjs
+++ b/apps/effectcraft-web/tests/workers.mjs
@@ -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"));
diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html
index 70ab93ff5..85049007f 100644
--- a/apps/effectcraft-web/web/index.html
+++ b/apps/effectcraft-web/web/index.html
@@ -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.
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index 8c7efc2e5..4acbeeeeb 100644
--- a/apps/effectcraft-web/web/sw.js
+++ b/apps/effectcraft-web/web/sw.js
@@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET" || url.origin !== self.location.origin) 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;
})(),
);
diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml
index 5ad6c80d1..e09e7d8b2 100644
--- a/apps/effectcraft/Cargo.toml
+++ b/apps/effectcraft/Cargo.toml
@@ -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 }
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 2f99bd5e0..64ab5443d 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -9,8 +9,8 @@
//!
//! `--control ` (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 {
}
}
+/// 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> {
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ {
+ let (tx, rx) = std::sync::mpsc::channel::();
+ 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::>()) {
+ 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"),
@@ -247,6 +283,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 +338,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 +358,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
}
@@ -355,15 +390,22 @@ fn wgpu_options(backends: Option) -> 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 +437,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>,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
@@ -408,14 +452,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,6 +483,9 @@ 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();
@@ -523,6 +580,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 +665,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");
+ }
}
diff --git a/apps/effectcraft/src/native_menu.rs b/apps/effectcraft/src/native_menu.rs
index 9db2ffe76..c3b142c5f 100644
--- a/apps/effectcraft/src/native_menu.rs
+++ b/apps/effectcraft/src/native_menu.rs
@@ -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,
spec: NativeMenu,
structure: u64,
- state: Option,
+ state: Option<(u64, bool)>,
events: Receiver,
/// The in-window-bar setting last applied (None: not yet).
in_window: Option,
@@ -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);
diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs
index 577eb0b64..911870190 100644
--- a/apps/effectcraft/src/sheet_dialogs.rs
+++ b/apps/effectcraft/src/sheet_dialogs.rs
@@ -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> + 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, 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 {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
@@ -35,10 +60,10 @@ fn path(file: Option) -> Vec {
}
/// 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()) {
diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs
index 8367d2a99..62c591b45 100644
--- a/apps/effectcraft/tests/args.rs
+++ b/apps/effectcraft/tests/args.rs
@@ -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")));
}
diff --git a/assets/fonts/NotoSansSymbols2-Regular.ttf b/assets/fonts/NotoSansSymbols2-Regular.ttf
new file mode 100644
index 000000000..caf89dd0e
Binary files /dev/null and b/assets/fonts/NotoSansSymbols2-Regular.ttf differ
diff --git a/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution b/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution
new file mode 100644
index 000000000..4d6d416be
--- /dev/null
+++ b/assets/fonts/NotoSansSymbols2-Regular.ttf.attribution
@@ -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.
diff --git a/assets/fonts/OFL-NotoSansSymbols2.txt b/assets/fonts/OFL-NotoSansSymbols2.txt
new file mode 100644
index 000000000..7c95767f5
--- /dev/null
+++ b/assets/fonts/OFL-NotoSansSymbols2.txt
@@ -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.
diff --git a/clippy.toml b/clippy.toml
index 4b6c79f55..3522a15b6 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -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
diff --git a/contributors/people.toml b/contributors/people.toml
index 2fa1927ce..1012a37bd 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -66,7 +66,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 +87,14 @@ 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"
diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs
index 52d5e4fb2..da78ae116 100644
--- a/crates/automation/tests/qa/compositing.rs
+++ b/crates/automation/tests/qa/compositing.rs
@@ -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,13 +29,12 @@ 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 seq = write_sequence(qa);
let imp = qa.exec("file.import", json!({"paths": [seq]}));
let seq_item = imp["items"][0].clone();
let project = qa.tool("get_project", json!({}));
@@ -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 {
diff --git a/crates/av1enc/README.md b/crates/av1enc/README.md
index 6ced20ba1..c922c95eb 100644
--- a/crates/av1enc/README.md
+++ b/crates/av1enc/README.md
@@ -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
diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs
index 0466a1592..777164d1a 100644
--- a/crates/av1enc/src/cdf.rs
+++ b/crates/av1enc/src/cdf.rs
@@ -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 {
diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs
index 494e914c0..9bbd56823 100644
--- a/crates/av1enc/src/decide.rs
+++ b/crates/av1enc/src/decide.rs
@@ -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, predict_inter, predict_intra, subpel_plane};
+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,
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; 3],
@@ -131,6 +178,8 @@ pub(crate) struct Decider<'a> {
lambda_sad: f64,
/// Half-sample interpolations of the reference luma (visible area): (8, 0), (0, 8), (8, 8).
half: [Vec; 3],
+ /// Transform buffers, taken out while a block is being tried (see `code_tx`).
+ scratch: Option>,
}
const MODE_BITS_INTRA: f64 = 3.0;
@@ -166,6 +215,7 @@ impl<'a> Decider<'a> {
Some(rf) => [(8, 0), (0, 8), (8, 8)].map(|(fx, fy)| subpel_plane(&rf.planes[0], g.width, g.height, fx, fy, bd)),
None => Default::default(),
},
+ scratch: None,
}
}
@@ -324,8 +374,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 +395,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>)> = 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 +426,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 +480,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 +508,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);
}
@@ -771,32 +833,38 @@ 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>) {
+ /// 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];
+ 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 };
@@ -808,23 +876,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 {
+ /// 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,10 +905,11 @@ 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;
+ }
}
}
diff --git a/crates/av1enc/src/lib.rs b/crates/av1enc/src/lib.rs
index 6b1edfbe8..3fcf446bb 100644
--- a/crates/av1enc/src/lib.rs
+++ b/crates/av1enc/src/lib.rs
@@ -409,11 +409,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));
diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs
index 2add41edc..cf350fbd2 100644
--- a/crates/av1enc/src/loopfilter.rs
+++ b/crates/av1enc/src/loopfilter.rs
@@ -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;
diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs
index d61a6fa94..7b354af1f 100644
--- a/crates/av1enc/src/pred.rs
+++ b/crates/av1enc/src/pred.rs
@@ -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);
@@ -340,7 +344,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));
diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs
index 90042e26a..048bfc0db 100644
--- a/crates/av1enc/src/transform.rs
+++ b/crates/av1enc/src/transform.rs
@@ -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 {
@@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
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.
+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 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) {
}
}
}
+}
+
+/// Evaluate the four-point core and merge progressively larger branches.
+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);
}
@@ -454,6 +464,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.
diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs
index 54c545fa4..a10bd2e9a 100644
--- a/crates/av1enc/src/writer.rs
+++ b/crates/av1enc/src/writer.rs
@@ -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; 3],
/// LoopfilterTxSizes per plane (row stride mi_cols + 32).
lf_tx: [Vec; 3],
+ /// Scratch for the levels coded so far in `coeffs`.
+ quant: Vec,
// 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 = 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) {
diff --git a/crates/av1enc/tests/bench.rs b/crates/av1enc/tests/it/bench.rs
similarity index 97%
rename from crates/av1enc/tests/bench.rs
rename to crates/av1enc/tests/it/bench.rs
index 6577f8f9f..b29817e1a 100644
--- a/crates/av1enc/tests/bench.rs
+++ b/crates/av1enc/tests/it/bench.rs
@@ -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;
diff --git a/crates/av1enc/tests/common/mod.rs b/crates/av1enc/tests/it/common/mod.rs
similarity index 100%
rename from crates/av1enc/tests/common/mod.rs
rename to crates/av1enc/tests/it/common/mod.rs
diff --git a/crates/av1enc/tests/ffmpeg_oracle.rs b/crates/av1enc/tests/it/ffmpeg_oracle.rs
similarity index 99%
rename from crates/av1enc/tests/ffmpeg_oracle.rs
rename to crates/av1enc/tests/it/ffmpeg_oracle.rs
index 66f380ac2..b35cbf496 100644
--- a/crates/av1enc/tests/ffmpeg_oracle.rs
+++ b/crates/av1enc/tests/it/ffmpeg_oracle.rs
@@ -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;
diff --git a/crates/av1enc/tests/it/main.rs b/crates/av1enc/tests/it/main.rs
new file mode 100644
index 000000000..c66bacde5
--- /dev/null
+++ b/crates/av1enc/tests/it/main.rs
@@ -0,0 +1,6 @@
+//! The crate's integration tests, one binary (each module is a former `tests/.rs`).
+
+mod bench;
+mod common;
+mod ffmpeg_oracle;
+mod roundtrip;
diff --git a/crates/av1enc/tests/roundtrip.rs b/crates/av1enc/tests/it/roundtrip.rs
similarity index 99%
rename from crates/av1enc/tests/roundtrip.rs
rename to crates/av1enc/tests/it/roundtrip.rs
index 1e777e509..282880e08 100644
--- a/crates/av1enc/tests/roundtrip.rs
+++ b/crates/av1enc/tests/it/roundtrip.rs
@@ -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};
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 1fae0ea90..2a0dfc50e 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -39,7 +39,13 @@ impl Rgba {
/// `#rrggbb` or `#rrggbbaa`.
pub fn from_hex(s: &str) -> Option {
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)?) }),
@@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
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 +136,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 +147,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 +262,28 @@ impl Label {
mod tests {
use super::*;
+ #[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 {
diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs
index ee7590eca..b570975aa 100644
--- a/crates/color/src/space.rs
+++ b/crates/color/src/space.rs
@@ -177,7 +177,7 @@ impl ColorSpace {
/// Parse an id or label (case-insensitive, loose).
pub fn parse(s: &str) -> Option {
- let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase();
+ let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase();
match k.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml
index c37d09949..bb26d3127 100644
--- a/crates/effects/Cargo.toml
+++ b/crates/effects/Cargo.toml
@@ -21,4 +21,5 @@ rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs
index e4f2b0348..929f41553 100644
--- a/crates/effects/src/blur2.rs
+++ b/crates/effects/src/blur2.rs
@@ -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]);
diff --git a/crates/effects/src/camera_tracker.rs b/crates/effects/src/camera_tracker.rs
index ff4e51fe4..ba7878abf 100644
--- a/crates/effects/src/camera_tracker.rs
+++ b/crates/effects/src/camera_tracker.rs
@@ -136,11 +136,11 @@ type Cache = Mutex>>;
fn cached(c: &'static OnceLock>, json: &str, parse: impl Fn(&str) -> Option) -> Option> {
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) {
+ if let Some(a) = c.lock().unwrap_or_else(std::sync::PoisonError::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());
+ let mut m = c.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if m.len() > 8 {
m.clear();
}
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 0be2b04f5..80a937b22 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -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 {
diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs
index b763ac21f..44e9dd89a 100644
--- a/crates/effects/src/channel.rs
+++ b/crates/effects/src/channel.rs
@@ -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();
diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs
index 7884e8084..08241107c 100644
--- a/crates/effects/src/channel3d.rs
+++ b/crates/effects/src/channel3d.rs
@@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec