From a6ab6a8f8cf7606426124bf4d0a4c97f0baec2ab Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:04:57 +0200 Subject: [PATCH 0001/1386] docs: add user docs for EffectCraft Adds docs/user/ (published at getartcraft.com/docs/effectcraft), the docs-check and docs-publish workflows (copied from craft-launcher), and a "User docs" rule in AGENTS.md. Verified against 0.8.0. Source notes per page: - index: README, crates/engine/src/menus.rs, website crafting-apps.ts entry, docs/gaps.md (.aep). - getting-started/install: README Downloads, gh release v0.8.0 assets, packaging/windows/effectcraft.wxs (per-machine, Start Menu, desktop shortcut box), package.ps1 (portable zip contents), launcher crates/core catalog.rs (EffectCraft listed), launcher docs install guide. - guides/make-a-composition: menus.rs (Composition, Layer, Help), comp.rs shortcut, panels/comp_settings.rs (labels, presets, tabs), panels/home.rs, engine/src/learn.rs (Animate a title tutorial). - guides/animate-with-keyframes: learn.rs, panels/timeline.rs (stopwatch, Add or remove keyframe), commands/prop.rs (Easy Ease F9), menus.rs (Animation). - guides/add-an-effect: menus.rs (Effect, Window), panels/effects_presets.rs, commands/effect.rs, effects/src/misc.rs (Gaussian Blur parameters). - guides/render-a-video: panels/render_queue.rs (labels), project render_queue.rs (format labels, default H.264, template, supports_alpha), commands/render_queue.rs (Cmd+M), README (Lottie). - guides/import-footage: docs/footage.md, menus.rs, engine/src/prefs.rs (Sequence Footage), README (Wayland drop). - troubleshooting/gpu-not-used: issues #596, #613; prefs.rs (Window Graphics), commands/app_more.rs (System Compatibility Report text), ui-egui menus.rs (GPU Information dialog text, checked at v0.8.0). - troubleshooting/footage-missing: docs/footage.md (relink), commands/ file_more.rs (Find Missing Footage), issue #652 and PR #676. - faq/after-effects-projects: README "Where it stands", docs/gaps.md. - faq/price-and-platforms: README (license, platforms, status), LICENSE files. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-check.yml | 32 ++++++++++ .github/workflows/docs-publish.yml | 22 +++++++ AGENTS.md | 6 ++ docs/user/AGENTS.md | 66 ++++++++++++++++++++ docs/user/CLAUDE.md | 1 + docs/user/faq/after-effects-projects.md | 22 +++++++ docs/user/faq/index.md | 8 +++ docs/user/faq/price-and-platforms.md | 24 +++++++ docs/user/getting-started/index.md | 8 +++ docs/user/getting-started/install.md | 54 ++++++++++++++++ docs/user/guides/add-an-effect.md | 33 ++++++++++ docs/user/guides/animate-with-keyframes.md | 33 ++++++++++ docs/user/guides/import-footage.md | 47 ++++++++++++++ docs/user/guides/index.md | 11 ++++ docs/user/guides/make-a-composition.md | 32 ++++++++++ docs/user/guides/render-a-video.md | 48 ++++++++++++++ docs/user/index.md | 36 +++++++++++ docs/user/troubleshooting/footage-missing.md | 33 ++++++++++ docs/user/troubleshooting/gpu-not-used.md | 38 +++++++++++ docs/user/troubleshooting/index.md | 10 +++ 20 files changed, 564 insertions(+) create mode 100644 .github/workflows/docs-check.yml create mode 100644 .github/workflows/docs-publish.yml create mode 100644 docs/user/AGENTS.md create mode 120000 docs/user/CLAUDE.md create mode 100644 docs/user/faq/after-effects-projects.md create mode 100644 docs/user/faq/index.md create mode 100644 docs/user/faq/price-and-platforms.md create mode 100644 docs/user/getting-started/index.md create mode 100644 docs/user/getting-started/install.md create mode 100644 docs/user/guides/add-an-effect.md create mode 100644 docs/user/guides/animate-with-keyframes.md create mode 100644 docs/user/guides/import-footage.md create mode 100644 docs/user/guides/index.md create mode 100644 docs/user/guides/make-a-composition.md create mode 100644 docs/user/guides/render-a-video.md create mode 100644 docs/user/index.md create mode 100644 docs/user/troubleshooting/footage-missing.md create mode 100644 docs/user/troubleshooting/gpu-not-used.md create mode 100644 docs/user/troubleshooting/index.md diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 000000000..b89a00a88 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,32 @@ +name: docs-check + +on: + pull_request: + paths: ["docs/user/**"] + push: + branches: [main] + paths: ["docs/user/**"] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Download the shared docs checker + id: fetch + run: | + url=https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs + if curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs"; then + echo "ok=1" >> "$GITHUB_OUTPUT" + else + echo "::warning::The docs checker is not published yet; skipping." + fi + - name: Check docs/user + if: steps.fetch.outputs.ok == '1' + run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 000000000..df4a412cc --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,22 @@ +name: docs-publish + +on: + push: + branches: [main] + paths: ["docs/user/**"] + +permissions: {} + +jobs: + rebuild: + runs-on: ubuntu-latest + steps: + - name: Rebuild getartcraft.com + env: + HOOK: ${{ secrets.DOCS_BUILD_HOOK }} + run: | + if [ -z "$HOOK" ]; then + echo "DOCS_BUILD_HOOK is not set; the nightly website build will pick this up." + exit 0 + fi + curl -fsS -X POST -d '{}' "$HOOK?trigger_title=docs%20update%20from%20$GITHUB_REPOSITORY" diff --git a/AGENTS.md b/AGENTS.md index aad29c94a..af320c781 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,12 @@ Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +## 4. User docs + +User docs live in `docs/user/` and are published at https://getartcraft.com/docs/effectcraft. If a +change alters something a user sees or does (menu or dialog labels, install, file formats, errors), +update the matching page in `docs/user/` in the same PR, or add one. Rules: `docs/user/AGENTS.md`. + ## See also diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 000000000..314ac1c58 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,66 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/effectcraft. The file path is the URL: +`troubleshooting/gpu-not-used.md` → `/docs/effectcraft/troubleshooting/gpu-not-used`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists using EffectCraft, Discord mods answering them, and AI assistants looking things up for +either. Write for an artist, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install EffectCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code, a release or an issue. +- Call the product "EffectCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. Menu paths use the menu text from + `crates/engine/src/menus.rs`. +- Shortcuts: the code writes `Cmd`, which is Ctrl on Windows and Linux. Write Ctrl for Windows. +- Windows 11 is the only Windows version to name. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: EffectCraft does not use my graphics card + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.8.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/effectcraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the workspace `version` in the root `Cargo.toml` whenever you check a +page against the current code. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + curl -fsSL https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs -o /tmp/docs-core.mjs + node /tmp/docs-core.mjs docs/user + +CI runs the same check on every PR that touches `docs/user/`. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): `. +Review them like code: check every claim against the source. Problems and bug reports go in the +[issue tracker](https://github.com/storytold/effectcraft/issues). diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/user/faq/after-effects-projects.md b/docs/user/faq/after-effects-projects.md new file mode 100644 index 000000000..2c22e6644 --- /dev/null +++ b/docs/user/faq/after-effects-projects.md @@ -0,0 +1,22 @@ +--- +title: Can EffectCraft open After Effects projects? +description: EffectCraft cannot open .aep or .aepx files, and After Effects plug-ins do not run. It saves .ecproj files and imports Lottie. +order: 10 +verified: 0.8.0 +--- + +No. EffectCraft cannot open After Effects projects (`.aep` or `.aepx`), and After Effects plug-ins do not run in it. + +## What it opens instead + +- Its own projects, saved as `.ecproj`. These are readable, versioned JSON files. +- Lottie animations, with **File ▸ Import ▸ Lottie...**. Each opens as a new composition. +- Photoshop and PDF or Illustrator files, imported as footage or as a composition. See [Import footage and image sequences](../guides/import-footage.md). + +## What carries over + +Expressions are JavaScript with the After Effects object model, such as `wiggle`, `loopOut` and `thisComp.layer("...")`. The panels, shortcuts and effect parameter names follow After Effects, so the skills carry over. + +## Status + +Reading `.aep` files is the largest known gap between EffectCraft and After Effects. It is listed in the project's [gap list](https://github.com/storytold/effectcraft/blob/main/docs/gaps.md), and the maintainers have not yet decided how to approach it. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 000000000..75b27177f --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Short answers about EffectCraft. +order: 40 +--- + +- [Can EffectCraft open After Effects projects?](after-effects-projects.md) +- [Is EffectCraft free, and where does it run?](price-and-platforms.md) diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md new file mode 100644 index 000000000..ce45a885d --- /dev/null +++ b/docs/user/faq/price-and-platforms.md @@ -0,0 +1,24 @@ +--- +title: Is EffectCraft free, and where does it run? +description: EffectCraft is free and open source under MIT or Apache-2.0, and runs on Windows, macOS, Linux and in the browser. +order: 20 +verified: 0.8.0 +--- + +Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2.0, at your option. It has no telemetry, and it uses no network access unless you ask for it. + +## Where it runs + +| Platform | Builds | +|----------|-------------------------------------------------------------------------------| +| Windows | x64, arm64 and x86, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | + +See [Install EffectCraft](../getting-started/install.md) for the files. + +## How mature is it? + +EffectCraft is in alpha. The README calls macOS the most tested platform, and says Linux and Windows users have hit basic interaction bugs that are being fixed first. Bug reports in the [issue tracker](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 000000000..6a3cad148 --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,8 @@ +--- +title: Getting started +description: Install EffectCraft and make your first composition. +order: 10 +--- + +- [Install EffectCraft](install.md) +- [Make your first composition](../guides/make-a-composition.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 000000000..7d2580972 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,54 @@ +--- +title: Install EffectCraft +description: Download EffectCraft for Windows 11, macOS or Linux, install it, or install it through ArtCraft Launcher. +order: 10 +verified: 0.8.0 +--- + +The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.8.0`. + +## Windows 11 + +1. Open the [latest release](https://github.com/storytold/effectcraft/releases/latest). +2. Download `effectcraft--windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). +3. Run the file and follow the installer. It asks where to install EffectCraft and offers a **Create a desktop shortcut** box. +4. Start **EffectCraft** from the Start menu. + +The release also has an `arm64` installer for Snapdragon and other ARM PCs, and an `x86` installer for 32-bit Windows. The installers are code-signed and install for all users. Later versions install into the same folder. + +To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` and `x86` builds have portable zips too. + +## macOS + +1. Download `effectcraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs and is signed and notarized. +2. Open it and drag EffectCraft to Applications. + +## Linux + +Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. + +| File | How to use it | +|-----------------------------------------------|------------------------------------------| +| `effectcraft--linux-x86_64.AppImage` | Run `chmod +x` on the file, then run it. | +| `effectcraft--linux-x86_64.deb` | For Debian and Ubuntu. | +| `effectcraft--linux-x86_64.rpm` | For Fedora, RHEL and openSUSE. | +| `effectcraft--linux-x86_64.flatpak` | `flatpak install --user ` | +| `effectcraft--linux-x86_64.tar.gz` | Unpack it anywhere. | + +There is also a RISC-V 64-bit `.tar.gz` (it needs glibc 2.39 or newer) and a FreeBSD x86_64 `.tar.gz`. + +## In the browser + +`effectcraft-web-.zip` is the web build. It is a static site that runs in a modern browser and needs to be hosted on a web server. Projects, media and settings stay in the browser and on your computer. + +## Install through ArtCraft Launcher + +ArtCraft Launcher lists EffectCraft. Click **Install** on its card. See [Install and update Craft apps](https://getartcraft.com/docs/craft-launcher/guides/install-and-update-apps). + +## Check the download + +`SHA256SUMS.txt` on the release page lists a checksum for every file. + +## Next + +Open EffectCraft and make something: [Make your first composition](../guides/make-a-composition.md). If you only want to look around, choose **Open Demo Project** on the Home screen. diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md new file mode 100644 index 000000000..a9732d539 --- /dev/null +++ b/docs/user/guides/add-an-effect.md @@ -0,0 +1,33 @@ +--- +title: Add an effect +description: Apply an effect such as Gaussian Blur to a layer and change its settings in Effect Controls. +order: 30 +verified: 0.8.0 +--- + +Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). + +## Steps + +1. Select a layer in the Timeline. +2. Apply an effect in one of two ways: + - Choose it from the **Effect** menu. Effects are grouped by category, for example **Effect ▸ Blur & Sharpen ▸ Gaussian Blur**. + - Open **Window ▸ Effects & Presets** (Ctrl+5). Type in the search field, then double-click the effect. Dragging it onto a layer works too. +3. Open **Window ▸ Effect Controls** (F3), or choose **Effect ▸ Effect Controls**. The effect's settings are listed there. **Gaussian Blur** has **Blurriness**, **Blur Dimensions** and **Repeat Edge Pixels**. +4. Change a value. Click the stopwatch beside it to animate it, as in [Animate with keyframes](animate-with-keyframes.md). + +If no layer is selected when you double-click an effect, EffectCraft shows **Select a layer to apply to**. Select a layer and try again. + +## Remove or reuse effects + +- **Effect ▸ Last Effect** applies the effect you used last. +- **Effect ▸ Remove All** takes every effect off the selected layers. +- In **Effects & Presets**, click the star on an effect to add it to Favorites. Recently used effects are listed too. + +## Layer styles + +For drop shadows, glows and bevels, use **Layer ▸ Layer Styles**. It lists **Drop Shadow**, **Inner Shadow**, **Outer Glow**, **Inner Glow**, **Bevel and Emboss** and **Satin**, among others. + +## Next + +[Render a video](render-a-video.md). diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md new file mode 100644 index 000000000..b591b43d3 --- /dev/null +++ b/docs/user/guides/animate-with-keyframes.md @@ -0,0 +1,33 @@ +--- +title: Animate with keyframes +description: Fly a layer in with two Position keyframes and smooth the motion with Easy Ease. +order: 20 +verified: 0.8.0 +--- + +Click the stopwatch beside a property to start animating it, move the current-time indicator, then change the value. The second keyframe appears by itself. Select both keyframes and press F9 to ease the motion. + +You need a composition with a layer. See [Make your first composition](make-a-composition.md). + +## Steps + +1. In the Timeline, twirl the layer open, then open **Transform**. +2. Set the starting value. For a layer that flies in from the left, scrub or type a **Position** that is outside the frame. +3. Click the stopwatch beside **Position**. It turns blue and records a keyframe at the current time. +4. Drag the blue current-time indicator to about 0:00:01:00, or click in the time ruler. +5. Drag the layer to its final place in the Composition viewer, or scrub the **Position** value. Because the stopwatch is on, a second keyframe appears by itself. +6. Select both keyframes (drag a box around them, or click the **Position** name) and press F9. This is **Animation ▸ Keyframe Assistant ▸ Easy Ease**. The layer now glides in and settles. +7. Press the Spacebar, or use Play in the Preview panel, to watch it. Press it again to stop. + +## Other things you can do + +- Click the keyframe button in the layer's property row to add or remove a keyframe at the current time. Its tooltip reads **Add or remove keyframe**. +- Choose **Animation ▸ Keyframe Assistant ▸ Easy Ease In** (Shift+F9) or **Easy Ease Out** to ease one side only. +- Choose **Animation ▸ Toggle Hold Keyframe** for a keyframe that jumps instead of moving. +- Choose **Animation ▸ Keyframe Interpolation...** or **Keyframe Velocity...** to set the curve exactly. +- Hold Alt and click a stopwatch to add an expression. +- The Graph Editor shows value and speed graphs with draggable handles. + +## Next + +[Add an effect](add-an-effect.md). diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md new file mode 100644 index 000000000..b5f424a09 --- /dev/null +++ b/docs/user/guides/import-footage.md @@ -0,0 +1,47 @@ +--- +title: Import footage and image sequences +description: Bring video, images and numbered image sequences into an EffectCraft project, and change how footage is read. +order: 50 +verified: 0.8.0 +--- + +Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. + +## Import files + +1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. +2. Photoshop and PDF or Illustrator files first ask whether to import them as footage or as a composition. +3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. + +You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. + +## Import an image sequence + +1. Choose **File ▸ Import ▸ File...** and pick any one numbered file of the run. +2. In the **Import Image Sequence** dialog, check the run it found. It also lists missing frames. +3. Tick the sequence option, which is named after the file type, for example **PNG Sequence**. When it is off, the files import as separate stills. It is off by default. +4. Set **Frame rate (fps)**. The default comes from **Sequence Footage** on the Import page of the preferences (**Edit ▸ Preferences ▸ Import...**), which is 30 fps. +5. Confirm. The run arrives as one footage item named like `shot_[0001-0250].png`. + +**Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. + +Stills in PNG, JPEG, TIFF, OpenEXR, BMP and WebP can form a sequence. PSD, SVG and PDF files import one by one. + +If the numbering skips a frame, EffectCraft shows colour bars for it. Put the missing files in the folder and choose **File ▸ Reload Footage** to fill the gap. + +## Change how footage is read + +Select the footage in the Project panel and choose **File ▸ Interpret Footage ▸ Main...** (Ctrl+Alt+G). The dialog has **Alpha**, **Assume this frame rate** and **Start Timecode**, among others. A change applies everywhere the footage is used. + +For example, a 250-frame sequence at 25 fps lasts 10 seconds. At 24 fps it lasts 10.4 seconds. + +## Replace or reload footage + +- **File ▸ Replace Footage ▸ File...** (Ctrl+H) points the item at another file. +- **File ▸ Reload Footage** (Ctrl+Alt+L) reads the files again. A sequence picks up frames added to its folder. + +If footage shows as missing, see [Footage is missing or offline](../troubleshooting/footage-missing.md). + +## Linux with Wayland + +Dragging files onto the window does not work on native Wayland sessions. Use **File ▸ Import ▸ File...** instead. diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 000000000..d8182d069 --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Guides +description: Step-by-step guides for the main tasks in EffectCraft. +order: 20 +--- + +- [Make your first composition](make-a-composition.md) +- [Animate with keyframes](animate-with-keyframes.md) +- [Add an effect](add-an-effect.md) +- [Render a video](render-a-video.md) +- [Import footage and image sequences](import-footage.md) diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md new file mode 100644 index 000000000..0234f183a --- /dev/null +++ b/docs/user/guides/make-a-composition.md @@ -0,0 +1,32 @@ +--- +title: Make your first composition +description: Create a composition, add a text layer and save the project as an .ecproj file. +order: 10 +verified: 0.8.0 +--- + +Choose **Composition ▸ New Composition...**, set the size, frame rate and duration, click **OK**, then add layers from the **Layer ▸ New** menu. Shortcuts below use Ctrl on Windows and Linux. On macOS, use Cmd. + +## Steps + +1. Choose **Composition ▸ New Composition...** (Ctrl+N). On the Home screen, click **New Composition** instead. +2. In the dialog, type a **Composition Name**. The default is `Comp 1`. +3. Pick a **Preset**, or set **Width**, **Height**, **Frame Rate** and **Duration**. The presets include `HDTV 1080 24`, `UHD 4K 25` and `Social Media Square · 1080x1080 · 30 fps`. +4. Click **OK**. The composition opens in the Composition panel and the Timeline. +5. Choose **Layer ▸ New ▸ Text**. A text layer appears at the top of the Timeline. +6. Add more layers from **Layer ▸ New**: **Solid...**, **Shape Layer**, **Null Object**, **Adjustment Layer**, **Light...** and **Camera...**. +7. Choose **File ▸ Save** to save the project. + +Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it also works with version control. + +## Change the settings later + +Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. + +## Next + +[Animate with keyframes](animate-with-keyframes.md). + +## Learn in the app + +The in-app tutorial **Animate a title** walks through these steps. To open it, choose **Help ▸ In-App Tutorials...**. diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md new file mode 100644 index 000000000..e3edcb0f9 --- /dev/null +++ b/docs/user/guides/render-a-video.md @@ -0,0 +1,48 @@ +--- +title: Render a video +description: Add a composition to the Render Queue, choose a format such as H.264, pick where to save and click Render. +order: 40 +verified: 0.8.0 +--- + +Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. + +## Steps + +1. Select the composition in the Project panel or open it. +2. Choose **Composition ▸ Add to Render Queue** (Ctrl+M). You can also drag compositions onto the Render Queue panel. Open the panel with **Window ▸ Render Queue** (Ctrl+Alt+0). +3. The queue lists the item with its **Comp Name**, **Status**, **Started** and **Render Time**. Twirl the item open to see **Render Settings:**, **Output Module:** and **Output To**. +4. Click **Output Module:** and choose a format. The menu entries read **Format: H.264**, **Format: QuickTime (ProRes)** and so on. +5. Click the file name next to **Output To** to choose where to save. Alt-click it to edit the name template instead. +6. Click **Render**. The status changes to **Rendering** with a percentage. Click **Stop** to cancel. + +Tick **Notify** to get a sound and a notification when the queue finishes. + +## Formats + +The **Output Module:** menu lists the formats your build can export: + +| Menu entry | File type | +|-----------------------|-------------------------------------| +| H.264 | `.mp4` | +| QuickTime (ProRes) | `.mov` | +| HEVC (H.265) | `.mp4` | +| AV1 | `.mp4` | +| WebM (VP9 + Opus) | `.webm` | +| PNG Sequence | `.png`, one file per frame | +| JPEG Sequence | `.jpg`, one file per frame | +| TIFF Sequence | `.tif`, one file per frame | +| OpenEXR Sequence | `.exr`, one file per frame | +| Animated GIF | `.gif` | +| WAV | `.wav`, audio only | +| AIFF | `.aif`, audio only | + +To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. + +## Render only part of a composition + +Click **Render Settings:** and choose **Time Span: Work Area Only** or **Time Span: Length of Comp**. The same menu sets **Quality: Best** or **Quality: Draft** and the resolution (**Resolution: Full**, **Half**, **Third** or **Quarter**). + +## Export a Lottie animation instead + +Choose **File ▸ Export ▸ Lottie JSON...** to write a composition as `.json` or `.lottie`. EffectCraft lists anything Lottie cannot express. To open one, choose **File ▸ Import ▸ Lottie...**. diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 000000000..babeaf8bb --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,36 @@ +--- +title: EffectCraft +description: EffectCraft is a free, open-source app for motion graphics and visual effects, an alternative to Adobe After Effects. +verified: 0.8.0 +--- + +EffectCraft is a free, open-source app for animated titles, motion graphics and compositing. It is an alternative to Adobe After Effects. + +You build a composition from layers (solids, shapes, text, footage, other compositions), animate their properties with keyframes, add effects and render the result. The panels and shortcuts follow After Effects: Project, Composition, Timeline, Effect Controls, Effects & Presets and the Render Queue. It runs on macOS, Windows and Linux, and in the browser. + +EffectCraft is young and in alpha. It cannot open After Effects projects (`.aep`), and some things still behave differently from After Effects. See [Can EffectCraft open After Effects projects?](faq/after-effects-projects.md). + +## Start here + +- [Install EffectCraft](getting-started/install.md) +- [Make your first composition](guides/make-a-composition.md) +- [Animate with keyframes](guides/animate-with-keyframes.md) +- [Add an effect](guides/add-an-effect.md) +- [Render a video](guides/render-a-video.md) +- [Import footage and image sequences](guides/import-footage.md) + +## When something is wrong + +- [EffectCraft does not use my graphics card](troubleshooting/gpu-not-used.md) +- [Footage is missing or offline](troubleshooting/footage-missing.md) + +## Sections + +- [Getting started](getting-started/index.md) +- [Guides](guides/index.md) +- [Troubleshooting](troubleshooting/index.md) +- [FAQ](faq/index.md) + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which version and which system you use. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md new file mode 100644 index 000000000..9e3314a01 --- /dev/null +++ b/docs/user/troubleshooting/footage-missing.md @@ -0,0 +1,33 @@ +--- +title: Footage is missing or offline +description: Find missing footage in an EffectCraft project, relink it to a folder or replace it with another file. +order: 20 +verified: 0.8.0 +--- + +Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. + +## Symptoms + +- A footage item in the Project panel is flagged as missing after you open a project. +- EffectCraft shows a message that footage items are missing. + +## Fix + +1. Choose **File ▸ Dependencies ▸ Find Missing Footage**. EffectCraft selects every missing item in the Project panel and shows how many it found. +2. Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder that holds your files. EffectCraft searches that folder and its subfolders. +3. It relinks files whose names are unique and whose type, dimensions and codec match what the project saved. The whole relink is one undo step. +4. For anything left over, select the item and choose **File ▸ Replace Footage ▸ File...** (Ctrl+H), then pick the file yourself. This also covers files with duplicate names in the folder, which Relink leaves alone. +5. After you copy or render files into a folder, choose **File ▸ Reload Footage** (Ctrl+Alt+L) to read them again. + +Relinking keeps item names, interpretation and layer timing. It does not change your files. For image sequences, every saved frame must be in the new folder. Relink Missing Footage is not available in the browser version. + +## Why this happens + +A project stores where each footage file is. If you move or rename the files, or open the project on another computer, the project cannot find them. + +Projects built with the command-line tool or an agent can use relative paths such as `bg.mp4`. Before 0.8.0, the app looked for those in its own working directory instead of next to the project file. Since 0.8.0 it also looks next to the project file ([pull request #676](https://github.com/storytold/effectcraft/pull/676)). The reporter on macOS has not confirmed the fix yet ([issue #652](https://github.com/storytold/effectcraft/issues/652)). + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Say which system you use, whether the project came from another computer or from the command-line tool, and where the footage is stored. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md new file mode 100644 index 000000000..7e3807f0d --- /dev/null +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -0,0 +1,38 @@ +--- +title: EffectCraft does not use my graphics card +description: What to do when the report says GPU none (CPU compositing), the window picked another adapter, or GPU effects fail on Windows 11 with DirectX 12. +order: 10 +verified: 0.8.0 +--- + +Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences and restart EffectCraft. + +## Symptoms + +- The report says `GPU: none (CPU compositing)` followed by a reason. +- The `Window graphics:` line names an adapter or backend you did not expect, for example an integrated or OpenGL adapter instead of your graphics card. + +## Fix + +1. Choose **Help ▸ System Compatibility Report...**. Copy the text. +2. Look at the `Window graphics:` line. If it shows the wrong adapter, continue. If it shows your card, go to **Still stuck?** below. +3. Choose **Edit ▸ Preferences ▸ Startup & Repair...**. +4. Set **Window Graphics** to **Automatic**. The other choice is **OpenGL (compatibility)**. +5. Close EffectCraft and start it again. The change applies from the next launch. +6. Open the report again and check that `Window graphics:` now names your card. + +One user whose GPU was not detected on 0.8.0 fixed it this way: they set the window graphics to Automatic, and EffectCraft started through Vulkan ([issue #596](https://github.com/storytold/effectcraft/issues/596)). + +## Why this happens + +When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL by itself. That keeps the app usable. Setting **Window Graphics** to **Automatic** lets EffectCraft choose the graphics backend again. + +The **GPU Information...** button on the Previews page of the preferences shows fixed text in 0.8.0. It does not name your GPU. Use the System Compatibility Report instead. + +## Windows 11 with DirectX 12 + +On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is open as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Include the whole System Compatibility Report, your graphics card and driver version, and whether you also have an integrated GPU. diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 000000000..0cf140136 --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,10 @@ +--- +title: Troubleshooting +description: Fixes for common EffectCraft problems. +order: 30 +--- + +- [EffectCraft does not use my graphics card](gpu-not-used.md) +- [Footage is missing or offline](footage-missing.md) + +Not listed? Open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues) or ask in [Discord](https://discord.gg/artcraft). Include the text from **Help ▸ System Compatibility Report...**. From d403aec0c34a7687a56acad19990ef5b8e7df4d5 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:11:36 +0200 Subject: [PATCH 0002/1386] docs: fix macOS menu paths and shortcut notes in EffectCraft docs Sources: crates/engine/src/menus.rs (EffectCraft > Settings... on macOS, lines ~475-495; Edit > Preferences [!mac], ~658-675), crates/project/src/render_queue.rs (RenderQueueItem.render defaults to true), issue #613 (RTX 5060 Ti comment), docs/web.md (browser private storage). Co-Authored-By: Claude Opus 5.5 --- docs/user/getting-started/install.md | 2 +- docs/user/guides/add-an-effect.md | 2 ++ docs/user/guides/animate-with-keyframes.md | 2 ++ docs/user/guides/import-footage.md | 4 +++- docs/user/guides/make-a-composition.md | 8 ++++---- docs/user/guides/render-a-video.md | 5 ++++- docs/user/troubleshooting/footage-missing.md | 2 ++ docs/user/troubleshooting/gpu-not-used.md | 6 ++++-- 8 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 7d2580972..f1c9ae1e8 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -39,7 +39,7 @@ There is also a RISC-V 64-bit `.tar.gz` (it needs glibc 2.39 or newer) and a Fre ## In the browser -`effectcraft-web-.zip` is the web build. It is a static site that runs in a modern browser and needs to be hosted on a web server. Projects, media and settings stay in the browser and on your computer. +`effectcraft-web-.zip` is the web build. It is a static site that runs in a modern browser and needs to be hosted on a web server. Projects, media and settings stay in the browser's private storage and on your computer. ## Install through ArtCraft Launcher diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md index a9732d539..f6e4d53ba 100644 --- a/docs/user/guides/add-an-effect.md +++ b/docs/user/guides/add-an-effect.md @@ -7,6 +7,8 @@ verified: 0.8.0 Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + ## Steps 1. Select a layer in the Timeline. diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md index b591b43d3..5b208185e 100644 --- a/docs/user/guides/animate-with-keyframes.md +++ b/docs/user/guides/animate-with-keyframes.md @@ -9,6 +9,8 @@ Click the stopwatch beside a property to start animating it, move the current-ti You need a composition with a layer. See [Make your first composition](make-a-composition.md). +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + ## Steps 1. In the Timeline, twirl the layer open, then open **Transform**. diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md index b5f424a09..542d65279 100644 --- a/docs/user/guides/import-footage.md +++ b/docs/user/guides/import-footage.md @@ -7,6 +7,8 @@ verified: 0.8.0 Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + ## Import files 1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. @@ -20,7 +22,7 @@ You can also drop files or folders on the window. Files dropped on the Compositi 1. Choose **File ▸ Import ▸ File...** and pick any one numbered file of the run. 2. In the **Import Image Sequence** dialog, check the run it found. It also lists missing frames. 3. Tick the sequence option, which is named after the file type, for example **PNG Sequence**. When it is off, the files import as separate stills. It is off by default. -4. Set **Frame rate (fps)**. The default comes from **Sequence Footage** on the Import page of the preferences (**Edit ▸ Preferences ▸ Import...**), which is 30 fps. +4. Set **Frame rate (fps)**. The default comes from **Sequence Footage** on the Import page of the preferences (**Edit ▸ Preferences ▸ Import...**; on macOS: **EffectCraft ▸ Settings... ▸ Import...**), which is 30 fps. 5. Confirm. The run arrives as one footage item named like `shot_[0001-0250].png`. **Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md index 0234f183a..9b32b3d41 100644 --- a/docs/user/guides/make-a-composition.md +++ b/docs/user/guides/make-a-composition.md @@ -23,10 +23,10 @@ Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. +## Learn in the app + +The in-app tutorial **Animate a title** walks through a similar title animation. To open it, choose **Help ▸ In-App Tutorials...**. + ## Next [Animate with keyframes](animate-with-keyframes.md). - -## Learn in the app - -The in-app tutorial **Animate a title** walks through these steps. To open it, choose **Help ▸ In-App Tutorials...**. diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md index e3edcb0f9..9d6c1955b 100644 --- a/docs/user/guides/render-a-video.md +++ b/docs/user/guides/render-a-video.md @@ -7,6 +7,8 @@ verified: 0.8.0 Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + ## Steps 1. Select the composition in the Project panel or open it. @@ -14,7 +16,8 @@ Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), 3. The queue lists the item with its **Comp Name**, **Status**, **Started** and **Render Time**. Twirl the item open to see **Render Settings:**, **Output Module:** and **Output To**. 4. Click **Output Module:** and choose a format. The menu entries read **Format: H.264**, **Format: QuickTime (ProRes)** and so on. 5. Click the file name next to **Output To** to choose where to save. Alt-click it to edit the name template instead. -6. Click **Render**. The status changes to **Rendering** with a percentage. Click **Stop** to cancel. +6. Check that the **Render** tick box at the left of the item is on. It is on by default, and only ticked items render. +7. Click **Render**. The status changes to **Rendering** with a percentage. Click **Stop** to cancel. Tick **Notify** to get a sound and a notification when the queue finishes. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md index 9e3314a01..f32b77c23 100644 --- a/docs/user/troubleshooting/footage-missing.md +++ b/docs/user/troubleshooting/footage-missing.md @@ -7,6 +7,8 @@ verified: 0.8.0 Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. + ## Symptoms - A footage item in the Project panel is flagged as missing after you open a project. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md index 7e3807f0d..0c56840db 100644 --- a/docs/user/troubleshooting/gpu-not-used.md +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -5,7 +5,7 @@ order: 10 verified: 0.8.0 --- -Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences and restart EffectCraft. +Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences (**Edit ▸ Preferences ▸ Startup & Repair...**; on macOS: **EffectCraft ▸ Settings... ▸ Startup & Repair...**) and restart EffectCraft. ## Symptoms @@ -16,7 +16,7 @@ Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Win 1. Choose **Help ▸ System Compatibility Report...**. Copy the text. 2. Look at the `Window graphics:` line. If it shows the wrong adapter, continue. If it shows your card, go to **Still stuck?** below. -3. Choose **Edit ▸ Preferences ▸ Startup & Repair...**. +3. Choose **Edit ▸ Preferences ▸ Startup & Repair...**. On macOS, choose **EffectCraft ▸ Settings... ▸ Startup & Repair...**. 4. Set **Window Graphics** to **Automatic**. The other choice is **OpenGL (compatibility)**. 5. Close EffectCraft and start it again. The change applies from the next launch. 6. Open the report again and check that `Window graphics:` now names your card. @@ -33,6 +33,8 @@ The **GPU Information...** button on the Previews page of the preferences shows On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is open as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. +The window can also take a long time to appear. On 0.8.0 with an RTX 5060 Ti, one user measured 69 seconds before the window showed on DirectX 12 without a working DXC shader compiler, against 3 seconds on Vulkan, which is what Automatic picked on that PC. If you close EffectCraft during that wait, the next launch uses OpenGL and has no GPU compositor until you set **Window Graphics** back to **Automatic** (steps above). So wait for the window on the first launch. + ## Still stuck? Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/effectcraft/issues). Include the whole System Compatibility Report, your graphics card and driver version, and whether you also have an integrated GPU. From 851f22cc31a8a558b2f37b08b73b4ea1676feec0 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:49:28 +0200 Subject: [PATCH 0003/1386] docs(user): apply final review fixes Items B-EffectCraft 1-2. Co-Authored-By: Claude Opus 5.5 --- docs/user/faq/price-and-platforms.md | 2 +- docs/user/getting-started/install.md | 4 ++-- docs/user/guides/animate-with-keyframes.md | 2 +- docs/user/guides/import-footage.md | 2 +- docs/user/guides/render-a-video.md | 2 +- docs/user/troubleshooting/footage-missing.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md index ce45a885d..e088f5c68 100644 --- a/docs/user/faq/price-and-platforms.md +++ b/docs/user/faq/price-and-platforms.md @@ -11,7 +11,7 @@ Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2 | Platform | Builds | |----------|-------------------------------------------------------------------------------| -| Windows | x64, arm64 and x86, as MSI installers and portable zips | +| Windows | x64 and arm64, as MSI installers and portable zips | | macOS | One universal app for Apple silicon and Intel | | Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | | FreeBSD | x86_64 tar.gz | diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index f1c9ae1e8..605fce2be 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -14,9 +14,9 @@ The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft 3. Run the file and follow the installer. It asks where to install EffectCraft and offers a **Create a desktop shortcut** box. 4. Start **EffectCraft** from the Start menu. -The release also has an `arm64` installer for Snapdragon and other ARM PCs, and an `x86` installer for 32-bit Windows. The installers are code-signed and install for all users. Later versions install into the same folder. +The release also has an `arm64` installer for Snapdragon and other ARM PCs. The installers are code-signed and install for all users. Later versions install into the same folder. -To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` and `x86` builds have portable zips too. +To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` build has a portable zip too. ## macOS diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md index 5b208185e..ceaa526f5 100644 --- a/docs/user/guides/animate-with-keyframes.md +++ b/docs/user/guides/animate-with-keyframes.md @@ -9,7 +9,7 @@ Click the stopwatch beside a property to start animating it, move the current-ti You need a composition with a layer. See [Make your first composition](make-a-composition.md). -Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. ## Steps diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md index 542d65279..5792e961b 100644 --- a/docs/user/guides/import-footage.md +++ b/docs/user/guides/import-footage.md @@ -7,7 +7,7 @@ verified: 0.8.0 Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. -Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. ## Import files diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md index 9d6c1955b..35c8a1c92 100644 --- a/docs/user/guides/render-a-video.md +++ b/docs/user/guides/render-a-video.md @@ -7,7 +7,7 @@ verified: 0.8.0 Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. -Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. ## Steps diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md index f32b77c23..9e859c636 100644 --- a/docs/user/troubleshooting/footage-missing.md +++ b/docs/user/troubleshooting/footage-missing.md @@ -7,7 +7,7 @@ verified: 0.8.0 Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. -Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. +Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option instead of Alt. ## Symptoms From ae3e1d10348a95149b82c3c9afd40c10ba1e3ba9 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:06:05 +0200 Subject: [PATCH 0004/1386] docs(user): apply final review fixes Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 6: re-pad platforms table. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 ++ docs/user/AGENTS.md | 3 +-- docs/user/faq/price-and-platforms.md | 14 +++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af320c781..4dc6d10ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,8 @@ as PhotoCraft does with `cargo xtask corpus`. User docs live in `docs/user/` and are published at https://getartcraft.com/docs/effectcraft. If a change alters something a user sees or does (menu or dialog labels, install, file formats, errors), update the matching page in `docs/user/` in the same PR, or add one. Rules: `docs/user/AGENTS.md`. +Pages describe the latest release. For a change that is not released yet, write that it was added +after and is not in a release yet, or update the page when the release ships. ## See also diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index 314ac1c58..cb00b812d 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -38,8 +38,7 @@ either. Write for an artist, not a developer. Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. -Set `verified` to the workspace `version` in the root `Cargo.toml` whenever you check a -page against the current code. +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show :`. Pages describe the latest release users download, not unreleased code on main. ## Folders diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md index e088f5c68..546d3a3d3 100644 --- a/docs/user/faq/price-and-platforms.md +++ b/docs/user/faq/price-and-platforms.md @@ -9,13 +9,13 @@ Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2 ## Where it runs -| Platform | Builds | -|----------|-------------------------------------------------------------------------------| -| Windows | x64 and arm64, as MSI installers and portable zips | -| macOS | One universal app for Apple silicon and Intel | -| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | -| FreeBSD | x86_64 tar.gz | -| Browser | A web build that runs the full app | +| Platform | Builds | +|----------|------------------------------------------------------------------------------| +| Windows | x64 and arm64, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | See [Install EffectCraft](../getting-started/install.md) for the files. From c421438012055b7e696ce8ddaf3a2ab0c223e18a Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:13:47 +0200 Subject: [PATCH 0005/1386] docs: pin the docs checker to a commit and its SHA-256 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-check.yml | 19 ++++++++++--------- docs/user/AGENTS.md | 5 +++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index b89a00a88..079b9d414 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -10,6 +10,12 @@ on: permissions: contents: read +env: + # Shared docs checker from storytold/artcraft-services, pinned to one commit + # and its SHA-256. To update it, change both values in a reviewed PR. + CHECKER_COMMIT: 4db0e30fe2c57a1c4c49e390e3711f90910b8d23 + CHECKER_SHA256: bd6b610a708b85fd0aa0491c459bd2800b716e0b8050516fea79e084cfb2a624 + jobs: check: runs-on: ubuntu-latest @@ -18,15 +24,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Download the shared docs checker - id: fetch + - name: Download the pinned docs checker run: | - url=https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs - if curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs"; then - echo "ok=1" >> "$GITHUB_OUTPUT" - else - echo "::warning::The docs checker is not published yet; skipping." - fi + url="https://raw.githubusercontent.com/storytold/artcraft-services/$CHECKER_COMMIT/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" + curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs" + echo "$CHECKER_SHA256 $RUNNER_TEMP/docs-core.mjs" | sha256sum -c - - name: Check docs/user - if: steps.fetch.outputs.ok == '1' run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index cb00b812d..0985231b1 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -53,10 +53,11 @@ drafts and are not published. ## Check before you push - curl -fsSL https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs -o /tmp/docs-core.mjs + commit=$(awk '/CHECKER_COMMIT:/ {print $2}' .github/workflows/docs-check.yml) + curl -fsSL "https://raw.githubusercontent.com/storytold/artcraft-services/$commit/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" -o /tmp/docs-core.mjs node /tmp/docs-core.mjs docs/user -CI runs the same check on every PR that touches `docs/user/`. +This uses the same pinned checker as CI (`.github/workflows/docs-check.yml`). ## Support PRs From 36b935aa1636a16e4a753653d86620a64fa40f16 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:09:20 +0200 Subject: [PATCH 0006/1386] docs: drop the docs workflows until the checker is on artcraft-services main Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-check.yml | 33 ------------------------------ .github/workflows/docs-publish.yml | 22 -------------------- docs/user/AGENTS.md | 6 ++---- 3 files changed, 2 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/docs-check.yml delete mode 100644 .github/workflows/docs-publish.yml diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml deleted file mode 100644 index 079b9d414..000000000 --- a/.github/workflows/docs-check.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: docs-check - -on: - pull_request: - paths: ["docs/user/**"] - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: - contents: read - -env: - # Shared docs checker from storytold/artcraft-services, pinned to one commit - # and its SHA-256. To update it, change both values in a reviewed PR. - CHECKER_COMMIT: 4db0e30fe2c57a1c4c49e390e3711f90910b8d23 - CHECKER_SHA256: bd6b610a708b85fd0aa0491c459bd2800b716e0b8050516fea79e084cfb2a624 - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Download the pinned docs checker - run: | - url="https://raw.githubusercontent.com/storytold/artcraft-services/$CHECKER_COMMIT/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" - curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs" - echo "$CHECKER_SHA256 $RUNNER_TEMP/docs-core.mjs" | sha256sum -c - - - name: Check docs/user - run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml deleted file mode 100644 index df4a412cc..000000000 --- a/.github/workflows/docs-publish.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: docs-publish - -on: - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: {} - -jobs: - rebuild: - runs-on: ubuntu-latest - steps: - - name: Rebuild getartcraft.com - env: - HOOK: ${{ secrets.DOCS_BUILD_HOOK }} - run: | - if [ -z "$HOOK" ]; then - echo "DOCS_BUILD_HOOK is not set; the nightly website build will pick this up." - exit 0 - fi - curl -fsS -X POST -d '{}' "$HOOK?trigger_title=docs%20update%20from%20$GITHUB_REPOSITORY" diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index 0985231b1..2c3add101 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -53,11 +53,9 @@ drafts and are not published. ## Check before you push - commit=$(awk '/CHECKER_COMMIT:/ {print $2}' .github/workflows/docs-check.yml) - curl -fsSL "https://raw.githubusercontent.com/storytold/artcraft-services/$commit/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" -o /tmp/docs-core.mjs - node /tmp/docs-core.mjs docs/user + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user -This uses the same pinned checker as CI (`.github/workflows/docs-check.yml`). +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. ## Support PRs From 47051fcb82bbd89e316c4ddf2347a59cfdea3d29 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:32:28 +0200 Subject: [PATCH 0007/1386] #1010: keep a parented camera's or light's Point of Interest in place Parenting a camera or light (layer.setParent, compensate on) now re-expresses its Position and Point of Interest in the new parent's space, like the other 3D layers. The 3D loop in parent_fixes skipped cameras and lights, so their positions stayed in the old space and the camera moved by the null's offset. Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 14 +++++++++++--- crates/engine/src/tests_rig3d.rs | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..5396df6b7 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -591,7 +591,8 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, /// A uniform relative parent transform can preserve static Orientation and Scale. pose_3d: Option<([f64; 3], f64)>, @@ -626,7 +627,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -646,6 +648,12 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); + } + if let Some(pr) = tr.get_mut("poi") { + map_point(pr); } if let Some((orientation, factor)) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { @@ -793,7 +801,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) { + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d3b965107..2027f4a05 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -207,6 +207,29 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From bb2153f112ead1c1c10e8c6246d70b663d8adcdf Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:37:05 +0200 Subject: [PATCH 0008/1386] #1010: recover a static Orientation's Y angle without losing precision near gimbal lock uniform_parent_pose took the Y angle from asin, which loses precision within about 1e-6 degrees of +-90. The Y angle now comes from atan2 on cos(Y), as the X and Z angles already do. The gimbal branch and the principal-range output are unchanged. A new test parents a static Orientation with Y 1e-6 degrees off gimbal lock under a uniform parent. Its largest corner displacement was 3.3e-8 px before this change and 1.5e-14 px after. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 7 +++--- crates/engine/tests/parenting_3d_position.rs | 23 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5396df6b7..c225612c3 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -751,9 +751,10 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti return Some((orientation, factor)); } let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; - let sy = (-r[2][0]).clamp(-1.0, 1.0); - let y = sy.asin(); - let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. + let cy = r[0][0].hypot(r[1][0]); + let y = (-r[2][0]).atan2(cy); + let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8ad..6bf0c4ff1 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -383,6 +383,29 @@ fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); } +/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose +/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there. +#[test] +fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { + let mut max_corners = 0.0f64; + for factor in [120.0, -120.0] { + for y in [90.0 - 1e-6, -90.0 + 1e-6] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); + set(&mut s, p, "transform/rotationX", json!(0.0)); + set(&mut s, p, "transform/rotationY", json!(0.0)); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0])); + set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); + let before = world(&s, c, Tick::ZERO); + parent(&mut s, c, Some(p), true); + max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO))); + } + } + assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}"); +} + #[test] fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { From 114c4797639eaf657e879dada91738b8cd72e76b Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:40:39 +0200 Subject: [PATCH 0009/1386] #1010: keep a rotated null's effect on a camera's or light's static Orientation Parenting a camera or light to a rotated uniform null now re-expresses its static Orientation, so a one-node light or camera keeps its direction. A two-node camera or light also keeps its roll: its Orientation is re-expressed against its new aim (aim_correction), so Up does not drift. Keyed Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 116 +++++++++++++++++++++------- crates/engine/src/tests_rig3d.rs | 54 ++++++++++++- 2 files changed, 142 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index c225612c3..0e76b9d0c 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -594,8 +594,17 @@ pub(crate) struct ParentFix { /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -605,7 +614,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -655,11 +664,12 @@ impl ParentFix { if let Some(pr) = tr.get_mut("poi") { map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -722,13 +732,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -747,15 +754,47 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. +fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + use effectcraft_geom::{Mat4, Vec3}; + let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the +/// authored values unchanged. +fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { + return orientation; + } + euler_of(correction, orientation) +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is @@ -807,23 +846,46 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); // Animated Euler Orientation and auto-orient cannot be re-expressed by // prepending a constant Euler value. Expression-driven values stay authored. + // Auto-orient replaces Orientation, except a two-node rig, whose roll is + // re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); if !orientation.keys.is_empty() || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 2027f4a05..6a13d8679 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -230,6 +230,58 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { assert!((f2 - f0).length() < 1e-6, "{f2:?}"); } +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From 707161bc631d68db5da6181c6e78b8ce4250dae7 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:43:16 +0200 Subject: [PATCH 0010/1386] #1010: re-express keyed Orientation per key under a rotated uniform parent Parenting a 3D layer under a rotated uniform parent now re-expresses keyed Orientation at every key, not only the static value. A Z-only correction shifts Z exactly, so the paths between keys stay exact. Other corrections keep each key's authored turns, so the Euler values stay continuous. This changes keyed 3D layers under any rotated uniform parent, not only cameras and lights. The boundary test now sets the non-uniform parent before the animated and static cases, so both keep their authored pose. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 83 +++++++++++++++----- crates/engine/src/tests_rig3d.rs | 18 +++++ crates/engine/tests/parenting_3d_position.rs | 25 +++++- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 0e76b9d0c..8222ad34e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -666,7 +666,7 @@ impl ParentFix { } if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { let factor = pose.factor; @@ -757,10 +757,15 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4 Some((rotation, factor)) } -/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. -fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - use effectcraft_geom::{Mat4, Vec3}; - let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); @@ -768,13 +773,55 @@ fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; [x.to_degrees(), y.to_degrees(), z.to_degrees()] } -/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the -/// authored values unchanged. -fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return orientation; +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only +/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other +/// corrections keep each key's authored turns, so the Euler values stay continuous. +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &mut pr.keys { + let o = key.value.as_vec3(); + // Each key keeps the authored change from the previous key on top of its converted value. + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + key.value = KV::Vec3(n); + prev = Some((o, n)); } - euler_of(correction, orientation) } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -848,13 +895,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op let orientation = tr.get("orientation")?; // Cameras and lights have no Scale; their Orientation is re-expressed alone. let scale = tr.get("scale"); - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - // Auto-orient replaces Orientation, except a two-node rig, whose roll is - // re-expressed against its new aim. + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); - if !orientation.keys.is_empty() - || orientation.expr.is_some() + if orientation.expr.is_some() || scale.is_some_and(|scale| scale.expr.is_some()) || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { @@ -873,7 +917,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op } else { rotation }; - if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } if let Some(scale) = scale { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 6a13d8679..87bb98699 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,6 +282,24 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } +/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its +/// direction at every key. +#[test] +fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; + let before = keys.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in keys.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); + } +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 6bf0c4ff1..3737d16e2 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { From 677922e2e2cfbf11940b44f673b6272491fe06aa Mon Sep 17 00:00:00 2001 From: untypequicode Date: Mon, 12 Oct 2026 19:13:04 +0200 Subject: [PATCH 0011/1386] Add a Nix flake (NixOS, GNOME and Hyprland) Builds the desktop app and the CLI from source with `nix build`, and offers packages, an overlay, `nix run`, a dev shell and a formatting check. - Cargo.lock drives the dependencies (git sources via allowBuiltinFetchGit). - Installs the desktop file, hicolor icons, MIME types and AppStream metadata from packaging/linux/, validated at build time. - Wraps the binaries so the dlopen'd libraries (libwayland-client, libxkbcommon(-x11), X11, Vulkan loader, libEGL, libdbus for the portal file chooser) are found on NixOS: native Wayland on GNOME and Hyprland, X11 as fallback. - Fonts from storytold/craft-fonts are pinned to the release workflow's revision where the app embeds them. - doCheck is off (the suite needs corpora and a GPU); `nix build` was verified. Co-Authored-By: Claude Sonnet 5.5 --- flake.lock | 27 +++++++ flake.nix | 106 +++++++++++++++++++++++++ nix/package.nix | 200 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/package.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..915f6ef3e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791594108, + "narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6510408d147d8c5e6d76043e899e9251756f6a75", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..38d8aa87c --- /dev/null +++ b/flake.nix @@ -0,0 +1,106 @@ +{ + description = "EffectCraft — motion graphics and visual effects, a free alternative to After Effects, in pure Rust"; + + inputs = { + # EffectCraft needs edition 2024 and rustc ≥ 1.95, so the flake tracks unstable. Consumers on a + # release channel can retarget it: `inputs.effectcraft.inputs.nixpkgs.follows = "nixpkgs";` + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { + self, + nixpkgs, + }: + let + inherit (nixpkgs) lib; + + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + # The AppStream metadata carries a release date. The flake source's own commit date is + # reproducible, unlike "now"; a tarball or dirty tree without one falls back to the epoch. + buildDate = + let + d = self.lastModifiedDate or ""; + in + if builtins.stringLength d >= 8 then + "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}" + else + "1970-01-01"; + + packageArgs = { + inherit buildDate; + }; + + forAllSystems = f: lib.genAttrs systems (system: f (import nixpkgs { inherit system; }) system); + in + { + packages = forAllSystems ( + pkgs: _: + let + effectcraft = pkgs.callPackage ./nix/package.nix packageArgs; + in + { + inherit effectcraft; + default = effectcraft; + } + ); + + # `nixpkgs.overlays = [ inputs.effectcraft.overlays.default ]` → `pkgs.effectcraft` everywhere. + overlays.default = final: _prev: { + effectcraft = final.callPackage ./nix/package.nix packageArgs; + }; + + apps = forAllSystems ( + _: system: { + default = { + type = "app"; + program = lib.getExe self.packages.${system}.effectcraft; + }; + } + ); + + devShells = forAllSystems ( + pkgs: system: + let + effectcraft = self.packages.${system}.effectcraft; + in + { + default = pkgs.mkShell ({ + # The package's own native inputs, so `cargo build` in the shell needs no extra setup. + inputsFrom = [ effectcraft ]; + packages = with pkgs; [ + cargo + rustc + clippy + rustfmt + rust-analyzer + ]; + # `cargo run` dlopens the Vulkan loader, libwayland and libxkbcommon like the packaged binary. + LD_LIBRARY_PATH = lib.makeLibraryPath effectcraft.passthru.runtimeLibs; + RUST_BACKTRACE = "1"; + }); + } + ); + + checks = forAllSystems ( + pkgs: system: { + effectcraft = self.packages.${system}.effectcraft; + + # `nix fmt` formats the flake; this keeps it formatted. + formatting = + pkgs.runCommand "effectcraft-nix-formatting" { nativeBuildInputs = [ pkgs.nixfmt ]; } + '' + nixfmt --check ${./flake.nix} ${./nix} + touch $out + ''; + } + ); + + formatter = forAllSystems (pkgs: _: pkgs.nixfmt); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..51b636c60 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,200 @@ +# EffectCraft — the desktop app (`effectcraft`) and the CLI/MCP server (`effectcraft-cli`), built from this +# repository: +# +# nix build # → ./result/bin/effectcraft, ./result/bin/effectcraft-cli +# +# Kept in step with the release workflow (.github/workflows/release.yml) and packaging/: +# * `Cargo.lock` drives the dependencies (`cargoLock`), so there is no vendorHash to bump. The +# git sources (storytold/craft-libs, storytold/filmcraft) are fetched at the revision the lock file pins; +# * the desktop file, hicolor icons, MIME types and AppStream metadata are the same files the +# .deb/.rpm ship (packaging/linux/), so `apt` and NixOS users see one identical EffectCraft; +# * `doCheck` is off: the test suite needs corpora and a GPU the Nix sandbox cannot provide +# (`cargo xtask ci` is the gate). `pkgs.effectcraft.overrideAttrs { doCheck = true; }` runs it. +# +# Everything in the product is pure Rust, so this needs no build system beyond cargo plus the +# libraries winit/wgpu `dlopen` at run time (see `runtimeLibs`). +# +# Desktop sessions: the window opens natively on Wayland (GNOME/Mutter, Hyprland and other +# wlroots-style compositors) and falls back to X11/XWayland. File dialogs go through +# xdg-desktop-portal, so the session needs a portal backend: GNOME ships one +# (`xdg-desktop-portal-gnome`); on Hyprland enable +# xdg.portal = { enable = true; extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; }; +# next to `programs.hyprland.enable = true;`. +{ + lib, + stdenv, + rustPlatform, + pkg-config, + makeWrapper, + # Windowing/GPU libraries: needed at build time by the crates that link them, at run time by the + # ones that open them with dlopen() (see `runtimeLibs`), and by the dev shell (flake.nix). + wayland, + wayland-scanner, + libxkbcommon, + libx11, + libxcb, + libxcursor, + libxi, + libxrandr, + vulkan-loader, + libglvnd, + # rfd opens libdbus-1 at run time to reach the xdg-desktop-portal file chooser (Open/Save dialogs). + dbus, + # Audio: cpal links ALSA, which reaches PipeWire/PulseAudio through the ALSA plug-ins. + alsa-lib, + # Validates the desktop file and the AppStream metadata we install (as CI does). + desktop-file-utils, + appstream, + # Adds the driver search path (/run/opengl-driver/lib) to the binaries' RPATH so wgpu finds the + # installed Vulkan/GL driver on NixOS. Optional: harmless on nixpkgs without it. + autoAddDriverRunpath ? null, + # Release date for the AppStream metadata (YYYY-MM-DD); the flake derives it from its own commit. + buildDate ? "1970-01-01", +}: + +let + root = ../.; + + # The version lives in exactly one place: `[workspace.package] version` in the root Cargo.toml. + version = (builtins.fromTOML (builtins.readFile (root + "/Cargo.toml"))).workspace.package.version; + + isLinux = stdenv.hostPlatform.isLinux; + + # AppStream id: also the desktop file name, and the icon name in share/icons/hicolor. + appId = "ai.storyteller.effectcraft"; + + # Libraries the binaries open at run time with dlopen(): nothing links them, so no RPATH points at + # them. winit loads libwayland-client, libxkbcommon(-x11) and the X11 libraries; wgpu loads the + # Vulkan loader or libEGL (libglvnd); rfd loads libdbus-1 for the file chooser portal. NixOS patches the loader to find the drivers in + # /run/opengl-driver. The wrapper below points LD_LIBRARY_PATH here. + runtimeLibs = lib.optionals isLinux [ + libxkbcommon + wayland + libx11 + libxcb + libxcursor + libxi + libxrandr + vulkan-loader + libglvnd + dbus + ]; + + # The two native binaries. The wasm app (apps/effectcraft-web) is built by `cargo xtask`, not here. + binaries = [ + "effectcraft" + "effectcraft-cli" + ]; + +in +rustPlatform.buildRustPackage { + pname = "effectcraft"; + inherit version; + + src = lib.cleanSourceWith { + src = lib.cleanSource root; + # The Nix files are not part of the build: editing them must not rebuild any Rust. + filter = + path: _type: + !(builtins.elem (baseNameOf path) [ + "flake.nix" + "flake.lock" + "nix" + ]); + }; + + cargoLock = { + lockFile = root + "/Cargo.lock"; + # The git dependencies are pinned by revision in Cargo.lock. + allowBuiltinFetchGit = true; + }; + + # Both binaries, as the .deb/.rpm install them (`packaging/linux/package.sh`). + cargoBuildFlags = [ + "--locked" + "-p" + "effectcraft" + "-p" + "effectcraft-cli" + ]; + + doCheck = false; + + strictDeps = true; + + nativeBuildInputs = [ + pkg-config + ] + ++ lib.optionals isLinux [ + makeWrapper + wayland-scanner + desktop-file-utils + appstream + ] + ++ lib.optionals (isLinux && autoAddDriverRunpath != null) [ autoAddDriverRunpath ]; + + buildInputs = lib.optionals isLinux [ + wayland + libxkbcommon + libx11 + libxcb + libxcursor + libxi + libxrandr + alsa-lib + ]; + + postInstall = '' + # Desktop integration: byte-for-byte the files the .deb/.rpm/.AppImage install. + install -Dm644 packaging/linux/${appId}.desktop $out/share/applications/${appId}.desktop + install -Dm644 packaging/linux/${appId}.mime.xml $out/share/mime/packages/${appId}.xml + cp -r assets/app-icon/hicolor $out/share/icons/ + install -d $out/share/metainfo + substitute packaging/linux/${appId}.metainfo.xml.in \ + $out/share/metainfo/${appId}.metainfo.xml \ + --subst-var-by VERSION ${version} \ + --subst-var-by DATE ${buildDate} + + # Licences for everything embedded in the binaries. + doc=$out/share/doc/effectcraft + for f in README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md assets/ATTRIBUTION.md; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + for f in assets/fonts/OFL-*.txt; do + if [ -f "$f" ]; then install -Dm644 "$f" "$doc/$(basename "$f")"; fi + done + '' + + lib.optionalString isLinux '' + desktop-file-validate $out/share/applications/${appId}.desktop + XDG_CACHE_HOME=$TMPDIR/.cache appstreamcli validate --no-net \ + $out/share/metainfo/${appId}.metainfo.xml + + # makeWrapper keeps the binaries working when the libs above are only dlopen'd. + for bin in ${lib.concatStringsSep " " (map (b: "$out/bin/" + b) binaries)}; do + wrapProgram "$bin" --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" + done + ''; + + passthru = { + # Shared with the dev shell so `cargo run` inside `nix develop` finds the same dlopen'd libs. + inherit runtimeLibs; + }; + + meta = { + description = "Motion graphics and visual effects application"; + longDescription = '' + EffectCraft is a motion graphics and visual effects application: compositions, keyframes, + effects and rendering. It ships with effectcraft-cli, a headless renderer, command runner and + MCP server for AI agents. + ''; + homepage = "https://getartcraft.com/apps/effectcraft"; + license = with lib.licenses; [ + mit + asl20 + ]; + sourceProvenance = with lib.sourceTypes; [ fromSource ]; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + # `nix run` and the desktop entry both start the GUI. + mainProgram = "effectcraft"; + }; +} From 6a8166491c8231ce252443650256736e29d26d0a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:47:37 +0200 Subject: [PATCH 0012/1386] Markers: adding one during playback keeps the cached frames and the sound (#1083, #1085) Markers counted as pixels in a comp's frame identity, so every marker edit dropped the cached frames; the playing preview then hit an uncached frame and stopped its sound. They now count only when an expression mentions markers. Co-Authored-By: Claude Sonnet 5.5 --- crates/project/src/lib.rs | 19 ++++++++-- crates/ui-egui/src/frames.rs | 48 +++++++++++++++++++++++++- crates/ui-egui/tests/ui_audio_scrub.rs | 28 +++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..d7f34b5b8 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -759,6 +759,12 @@ impl Switches { impl Layer { /// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches. pub fn same_pixels(&self, o: &Layer) -> bool { + self.same_pixels_with(o, true) + } + + /// [`Self::same_pixels`], with the markers counting only when `with_markers` (they are read by + /// expressions alone, so a project without any can edit them freely). + pub fn same_pixels_with(&self, o: &Layer, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here); the property tree last. let Layer { id, @@ -796,7 +802,7 @@ impl Layer { && *preserve_transparency == o.preserve_transparency && *track_matte == o.track_matte && *parent == o.parent - && *markers == o.markers + && (!with_markers || *markers == o.markers) && *markers_locked == o.markers_locked && *auto_orient == o.auto_orient && *environment == o.environment @@ -809,6 +815,13 @@ impl Comp { /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { + self.same_pixels_with(o, true) + } + + /// [`Self::same_pixels`], with the comp's and its layers' markers counting only when + /// `with_markers`: only expressions read them, so while none does, adding or moving a marker + /// (even during playback) leaves every rendered frame valid. + pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool { // Every field (no `..`: a new field must be considered here); the layers last. let Comp { width, @@ -846,7 +859,7 @@ impl Comp { && *display_start == o.display_start && *background == o.background && *work_area == o.work_area - && *markers == o.markers + && (!with_markers || *markers == o.markers) && *shutter_angle == o.shutter_angle && *shutter_phase == o.shutter_phase && *motion_blur_samples == o.motion_blur_samples @@ -862,7 +875,7 @@ impl Comp { && *guides == o.guides && *essential == o.essential && layers.len() == o.layers.len() - && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers)) } /// The comp with those switches and the Resolution at their defaults (borrowed when they diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9a3398d62..0923e5eb8 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -291,6 +291,18 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, |_, c| Arc::as_ptr(c)) } +/// Some expression in the project mentions markers (`marker.key(1)`, `numKeys`, `nearestKey`…). +/// Only then do the markers of its comps and layers affect what is rendered. +fn reads_markers(project: &Project) -> bool { + fn group(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.expr.as_ref().is_some_and(|e| e.text.to_ascii_lowercase().contains("marker")), + Node::Group(sub) => group(sub), + }) + } + project.comps().any(|(_, c)| c.layers.iter().any(|l| group(&l.props))) +} + /// [`comp_content`] with each comp's address given by `addr`. fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc) -> *const Comp) -> u64 { use std::hash::{Hash, Hasher}; @@ -703,10 +715,12 @@ impl Frames { fn identity(&self, project: &Arc, comp: ItemId) -> Identity { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; + // Markers are pixels only to the expressions that read them (#1083). + let markers = reads_markers(project); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels(c) => Arc::as_ptr(b), + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => Arc::as_ptr(b), _ => Arc::as_ptr(c), }) == *base_id { @@ -1970,4 +1984,36 @@ mod tests { switch(&mut s, inner, solid, "video", true); assert_ne!(at(&s), o1); } + + /// Adding, moving or deleting a marker (Num* while a preview plays) keeps the cached frames: + /// the sound would otherwise stop at the next frame that has to be rendered again (#1083). + /// Only expressions that read markers make them part of the pixels. + #[test] + fn markers_keep_the_frames_unless_an_expression_reads_them() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer"); + let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 2})).unwrap(), "comp")); + id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer"); + let f = Frames::default(); + let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c); + let (o0, i0) = (at(&s, outer), at(&s, inner)); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 0.5, "comment": "beat"})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.0})).unwrap(); + s.execute("comp.addMarker", json!({"comp": outer.0, "time": 1.5})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "comp markers"); + s.execute("markers.set", json!({"comp": inner.0, "layer": solid, "new": true, "time": 0.25})).unwrap(); + assert_eq!((at(&s, outer), at(&s, inner)), (o0, i0), "layer marker"); + // An expression reading markers makes them count again. + s.execute("prop.setExpression", json!({"comp": inner.0, "layer": solid, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})) + .unwrap(); + let (o1, i1) = (at(&s, outer), at(&s, inner)); + assert_ne!(o1, o0); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + assert_ne!(at(&s, inner), i1, "marker read by an expression"); + assert_ne!(at(&s, outer), o1, "marker read by an expression in a nested comp"); + } } diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index 02fefb14f..7c9ec439f 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -192,3 +192,31 @@ fn preview_with_audio_sounds_once_rendering_keeps_up() { } assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); } + +/// Adding markers while a preview sounds (Num*, one per beat) keeps the sound going (#1083): a +/// marker changes no pixels, so no cached frame is dropped and no frame has to be rendered again +/// (which would stop the sound until the frames ahead are cached). +#[test] +fn adding_markers_does_not_stop_the_sound() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + // Everything is cached before the preview starts, so the sound starts at once. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app); + h.run_steps(2); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + while h.state().audio.is_none() && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(h.state().audio.is_some(), "the sound started"); + for beat in 0..6 { + let t = 0.1 + 0.05 * beat as f64; + h.state_mut().session.execute("comp.addMarker", json!({"comp": cid.0, "time": t, "comment": "beat"})).unwrap(); + h.step(); + let app = h.state(); + assert!(app.playback.playing && app.audio.is_some() && !app.playback.audio_held, "marker {beat}: still playing with sound"); + } + assert_eq!(h.state().session.project.comp(cid).unwrap().markers.len(), 6); +} From cdd6d83abb5c55b70dc2f0bc090df21c8390a52b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:52:29 +0200 Subject: [PATCH 0013/1386] Markers: the disk cache key ignores them too unless an expression reads them (#1083) Project::reads_markers and Comp::pixel_form_with are shared by the RAM and disk frame caches. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_disk_cache.rs | 20 ++++++++++++++++ crates/project/src/lib.rs | 33 ++++++++++++++++++++++++++- crates/render/src/disk_cache.rs | 7 ++++-- crates/ui-egui/src/frames.rs | 14 +----------- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs index 3091964ad..9c4daa954 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests_disk_cache.rs @@ -136,3 +136,23 @@ fn footage_changes_change_the_salt() { assert_ne!(k3, k2); assert_ne!(set_proxy(&mut s, false), k3); } + +/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to +/// render again, #1083); an expression that reads markers makes them count. +#[test] +fn markers_keep_the_content_key_unless_an_expression_reads_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap(); + assert_eq!(key(&s), k0, "markers"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0); + s.execute("comp.addMarker", json!({"time": 1.0})).unwrap(); + assert_ne!(key(&s), k1, "a marker read by an expression"); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index d7f34b5b8..57824de8c 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -881,14 +881,30 @@ impl Comp { /// The comp with those switches and the Resolution at their defaults (borrowed when they /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + self.pixel_form_with(true) + } + + /// [`Self::pixel_form`], also without the comp's and its layers' markers when not + /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]). + pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> { + if !self.hide_shy + && self.resolution.is_auto() + && self.layers.iter().all(|l| l.switches == l.switches.pixels()) + && (with_markers || (self.markers.is_empty() && self.layers.iter().all(|l| l.markers.is_empty()))) + { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; c.resolution = Resolution::Auto; + if !with_markers { + c.markers.clear(); + } for l in &mut c.layers { l.switches = l.switches.pixels(); + if !with_markers { + l.markers.clear(); + } } std::borrow::Cow::Owned(c) } @@ -1339,6 +1355,21 @@ impl Default for Project { } impl Project { + /// Some expression mentions markers (`marker.key(1)`, `numKeys`, `nearestKey`...). Only then + /// do the markers of comps and layers affect what is rendered, so frame caches ignore them + /// otherwise. + pub fn reads_markers(&self) -> bool { + let mut found = false; + for (_, c) in self.comps() { + for l in &c.layers { + if !found { + l.props.walk("", &mut |_, p| found |= p.expr.as_ref().is_some_and(|e| e.text.to_ascii_lowercase().contains("marker"))); + } + } + } + found + } + /// Allocate an id (items, layers and property uids share one counter). pub fn alloc(&mut self) -> u64 { let id = self.next_id; diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index da46708e7..9c3655f72 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -780,7 +780,8 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { /// Content key of a composition's pixels: project render settings, the comp and everything it /// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy /// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. +/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and +/// neither do markers unless an expression mentions them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); @@ -805,13 +806,15 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf } } } + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); h.write(it.name.as_bytes()); match &it.kind { - ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()), + ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)), k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 0923e5eb8..e41a06f7e 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -291,18 +291,6 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, |_, c| Arc::as_ptr(c)) } -/// Some expression in the project mentions markers (`marker.key(1)`, `numKeys`, `nearestKey`…). -/// Only then do the markers of its comps and layers affect what is rendered. -fn reads_markers(project: &Project) -> bool { - fn group(g: &PropGroup) -> bool { - g.children.iter().any(|c| match c { - Node::Prop(p) => p.expr.as_ref().is_some_and(|e| e.text.to_ascii_lowercase().contains("marker")), - Node::Group(sub) => group(sub), - }) - } - project.comps().any(|(_, c)| c.layers.iter().any(|l| group(&l.props))) -} - /// [`comp_content`] with each comp's address given by `addr`. fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc) -> *const Comp) -> u64 { use std::hash::{Hash, Hasher}; @@ -716,7 +704,7 @@ impl Frames { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; // Markers are pixels only to the expressions that read them (#1083). - let markers = reads_markers(project); + let markers = project.reads_markers(); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { From 08d024e8866f0b1751fb323772511d1daf1d0006 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:00:52 +0200 Subject: [PATCH 0014/1386] #1063: find a Levels alpha control on the effect by its name Pro Levels2 keeps its Alpha controls one group down, under Alpha, and a script's effect.property("Alpha Input White") only searched the effect's direct children, so it returned null. The lookup now searches the groups nested below the direct children, up to three levels, after the direct match fails. Direct children keep their priority. A regression test checks the name on the effect and the same control through the Alpha group. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 49 +++++++++++++++++++++++++------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..dfdf458c9 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -402,6 +402,38 @@ fn alias(k: &str) -> Option<&'static str> { }) } +/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha +/// controls sit one group down, under Alpha). +const NESTED_CHILD_DEPTH: u8 = 3; + +/// The child of `g` that a string key names. The direct children are searched first, then the +/// groups nested below them, up to `depth` levels. +fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> { + let chain_of = |n: &Node| -> String { + let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + .or_else(|| { + if depth == 0 { + return None; + } + g.children.iter().find_map(|n| match n { + Node::Group(sub) => find_child(l, sub, k, depth - 1), + Node::Prop(_) => None, + }) + }) +} + fn child(s: &Session, a: &J) -> R { let (_, c, l) = layer_of(s, a)?; let uid = u(a, "uid").unwrap_or(0); @@ -409,22 +441,7 @@ fn child(s: &Session, a: &J) -> R { let key = a.get("key").cloned().unwrap_or(J::Null); let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), - J::String(k) => { - let chain_of = |n: &Node| -> String { - let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; - ch.push(n); - matchnames::node(&ch) - }; - let lk = loose(k); - g.children - .iter() - .find(|n| chain_of(n) == *k) - .or_else(|| g.children.iter().find(|n| n.name() == k)) - .or_else(|| g.children.iter().find(|n| n.match_id() == k)) - .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) - .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) - .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) - } + J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), _ => None, }; match found { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..27812b825 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,21 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their +/// name on the effect as well as through the group (#1063). +#[test] +fn scripted_levels_alpha_controls_resolve_from_the_effect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30); + var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2"); + fx.property("Alpha Input White").setValue(0.5); + [fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value] + "#, + ); + assert_eq!(o.result, json!([0.5, 0.5])); +} From 55ce6017107557ab0c8cc2a26e070a70e9636648 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:48:16 +0200 Subject: [PATCH 0015/1386] #1063: return the text animator property a script asked for addProperty("ADBE Text Tracking Amount") returned the first property added to the animator, which is the Tracking Type companion, so the script changed the wrong value. The engine now reports the requested property's position in the Properties group, and the script returns that property. An animator's Properties group also finds its properties by their After Effects names, such as ADBE Text Tracking Amount, which the name table already had in the other direction. A regression test checks the property returned, its value, and the lookup by name. Not covered: the Advanced and Wiggly selector children and the Expression amount, which still keep their own names. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/text_anim.rs | 6 +++++- crates/script/src/model.rs | 28 ++++++++++++++++++------- crates/script/src/prelude.js | 5 ++--- crates/script/src/tests.rs | 20 ++++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d48424..fff32c2ff 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -139,6 +139,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -148,10 +149,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index dfdf458c9..9df540b86 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -415,11 +415,14 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option< matchnames::node(&ch) }; let lk = loose(k); + // An animator's Properties group also answers to the After Effects names of its properties. + let animator_props = g.match_id == "properties"; g.children .iter() .find(|n| chain_of(n) == k) .or_else(|| g.children.iter().find(|n| n.name() == k)) .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k))) .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) @@ -671,10 +674,9 @@ fn resolve_add(s: &Session, a: &J) -> R { }) } -/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match -/// name or a display name. -fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ +/// The After Effects match name and key of each text animator property. +fn animator_property_table() -> &'static [(&'static str, &'static str)] { + &[ ("ADBE Text Anchor Point 3D", "anchor"), ("ADBE Text Position 3D", "position"), ("ADBE Text Scale 3D", "scale"), @@ -698,12 +700,24 @@ fn text_animator_property(name: &str) -> Option<&'static str> { ("ADBE Text Character Offset", "characterOffset"), ("ADBE Text Character Replace", "characterValue"), ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + ] +} + +/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match +/// name or a display name. +fn text_animator_property(name: &str) -> Option<&'static str> { + let table = animator_property_table(); + if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) +} + +/// The After Effects match name of a text animator property key (`tracking` is +/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063). +fn animator_property_match_name(key: &str) -> Option<&'static str> { + animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..dc159b878 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -718,9 +718,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 27812b825..7170652bf 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1028,3 +1028,23 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() { ); assert_eq!(o.result, json!([0.5, 0.5])); } + +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"])); +} From 95f2f3ec5528fd2d51716cd48fd043480c4a245f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:52:08 +0200 Subject: [PATCH 0016/1386] #1010: leave keyed Orientation authored under a tilted parent Euler angles are blended between keys, so re-expressing only the key values cannot keep the motion between them under a tilted parent. Keyed Orientation is now re-expressed only for a Z-only correction, which shifts Z exactly at every time; static Orientation is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 23 ++++++++------------- crates/engine/src/tests_rig3d.rs | 32 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8222ad34e..7208c1ff1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,9 +789,10 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } -/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only -/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other -/// corrections keep each key's authored turns, so the Euler values stay continuous. +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key, so the paths between keys stay exact too. Any other correction is applied +/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are +/// blended between keys, which no re-expression of the keys can reproduce. fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -810,18 +811,6 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); - let mut prev: Option<([f64; 3], [f64; 3])> = None; - for key in &mut pr.keys { - let o = key.value.as_vec3(); - // Each key keeps the authored change from the previous key on top of its converted value. - let reference = match prev { - Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], - None => o, - }; - let n = reexpress_euler(correction, o, reference); - key.value = KV::Vec3(n); - prev = Some((o, n)); - } } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -923,6 +912,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } + // Keys can only follow a Z-only correction exactly; otherwise they stay authored. + if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { + return None; + } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 87bb98699..ecae51cb1 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,22 +282,38 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } -/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its -/// direction at every key. +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. #[test] -fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { let mut s = session(); let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); } - let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; - let before = keys.map(|t| frame_at(&s, light, t)); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so +/// keyed Orientation stays as authored rather than changing the motion between the keys. +#[test] +fn keyed_orientation_under_a_tilted_null_stays_authored() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - for (t, b) in keys.into_iter().zip(before) { - assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); - } + let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); + assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From e78f115d71ca5ab4ec9fc4e073836cb3cb445ccc Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:57:25 +0200 Subject: [PATCH 0017/1386] #1010: add keys so keyed Orientation follows a tilted parent between the keys Under a tilted parent, the converted keys alone give a different path between them, since Euler angles are blended. Keys are now added wherever the blend drifts more than 0.05 degrees from the exact rotation (halving each segment, at most 10 times and 4096 keys). The segments become straight blends with the original easing baked in; hold segments are kept. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 95 ++++++++++++++++++++++++++--- crates/engine/src/tests_rig3d.rs | 31 ++++++---- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 7208c1ff1..c112a4912 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,10 +789,48 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= BAKE_MAX_KEYS { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, out); + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, out); +} + /// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the -/// value and every key, so the paths between keys stay exact too. Any other correction is applied -/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are -/// blended between keys, which no re-expression of the keys can reproduce. +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -811,6 +849,53 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, &mut between); + // An eased segment, or one that gained keys, becomes a run of straight blends. + if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -912,10 +997,6 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } - // Keys can only follow a Z-only correction exactly; otherwise they stay authored. - if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { - return None; - } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index ecae51cb1..d867337ac 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -300,20 +300,27 @@ fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { } } -/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so -/// keyed Orientation stays as authored rather than changing the motion between the keys. +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. #[test] -fn keyed_orientation_under_a_tilted_null_stays_authored() { - let mut s = session(); - let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); - for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { - s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); } - let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); - s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From baf62b22dfb0772453d3606c04fc40f609b097e0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:38:20 -0400 Subject: [PATCH 0018/1386] Preview: preserve opening audio while preparing video playback (#947) Cause: a preview with sound and video, without Cache Before Playback, held its sound while the playhead advanced silently through frames as they rendered, and started the sound at the frame it had reached once the frames ahead were cached or rendering had kept up for a quarter second. So the opening (about the first third of a second) was never heard, and those frames played silently. With Video off, or Cache Before Playback, the sound started at the first frame. Fix: starting such a preview now holds the playhead on its first frame while the frames ahead render (the prefetch window, at most what fits in the RAM preview: not the whole work area), then starts sound and picture together from that frame, the sound's clock driving the picture. Without an output device the picture plays from that frame on its own clock. A stall later on still stops the sound and catches up silently. Stop clears the startup state. Tests (ui_audio_scrub): the two startup tests no longer accept silent frames first: the playhead must hold frame 0 until the sound starts, and the sound must start at frame 0 (also for a 30 s work area, without caching it all). A tone lasting only the first 100 ms after a start at 0 s and at 1 s reaches the output device. Video off still starts at once; no device plays the picture; Stop while preparing leaves nothing playing. The three startup tests fail on the previous code (the playhead had moved on to frame 1, silently). --- crates/ui-egui/src/lib.rs | 35 ++++- crates/ui-egui/tests/ui_audio_scrub.rs | 191 ++++++++++++++++++------- 2 files changed, 168 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 746d917bc..fb26641a3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -217,6 +217,12 @@ pub struct Playback { /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, + /// Starting a preview with sound (#947): the playhead stays on the first frame while the + /// frames ahead render (the prefetch window, at most what fits in the RAM preview), then + /// sound and picture start together from it. Without this the picture played on silently + /// while they rendered and the sound started at the later frame, so the opening was lost. + /// A stall later on still falls back to [`Playback::audio_held`]. + pub starting: bool, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } @@ -1004,11 +1010,14 @@ impl EffectcraftApp { restore_max, audio_frame: (!plan.video).then_some(plan.first), audio_held: false, + starting: false, shown_at: Default::default(), }; if !self.playback.caching { if plan.video { - self.playback.audio_held = self.audio_plays(); + let audible = self.audio_plays(); + self.playback.audio_held = audible; + self.playback.starting = audible; } else { self.start_audio(fr.tick_of(plan.first)); } @@ -1068,6 +1077,7 @@ impl EffectcraftApp { let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; self.playback.caching = false; + self.playback.starting = false; if !was { return; } @@ -1262,9 +1272,28 @@ impl EffectcraftApp { return; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - // Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the - // prefetch window when the silent preview keeps real time, else the rest of the preview. let fit = self.frames_that_fit(&c, scale); + // Starting with sound: hold the first frame until the frames ahead are cached, then start + // the sound from that same frame (see `Playback::starting`). Without an output device the + // picture plays on its own clock from it. + if self.playback.starting + && let Some(p) = plan + { + self.playback.start_wall = now; + self.playback.start_frame = cur; + if !self.cached_ahead(&p, &series, cur, ahead.min(fit)) { + self.playback.waiting = true; + ctx.request_repaint(); + return; + } + self.playback.starting = false; + self.playback.audio_held = false; + self.playback.waiting = false; + self.start_audio(fr.tick_of(cur)); + } + // Held sound starts once the frames ahead are cached again after a stall (see + // `Playback::audio_held`): the prefetch window when the silent preview keeps real time, + // else the rest of the preview. if self.playback.audio_held && let Some(p) = plan && ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit)) diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index 02fefb14f..2d256475d 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -113,47 +113,55 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() { assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped"); } -/// A preview with audio waits for frames like a silent one (#103): while the frames ahead aren't -/// cached, every frame shows as it renders, silently; the sound starts once the rest of the -/// preview is cached, and a frame that isn't cached stops it instead of being skipped. -#[test] -fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { - let fake = Fake::default(); - let (mut app, cid) = setup(true, &fake); - // Playback requests at most twice as many frames ahead per step as there are render threads. - // Four times that many frames take at least four steps to cache, so frames are shown silently - // first however fast the renderer is (with a fixed 2 s comp, a machine with many cores cached - // all of it before the first step and the sound started at once). - let ahead = (app.frames.parallelism() * 2).max(4); - app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); - // A little under one frame of input time per step. - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app); - h.run_steps(2); - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) - }; - let now = h.ctx.input(|i| i.time); - h.state_mut().play(now); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - let mut shown = 0; - // Wait on the clock, not a step count: under a loaded machine (parallel test binaries) the - // background cache can take far longer than 2000 short steps to get ahead. +/// The comp's frame at the current time. +fn frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> i64 { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) +} + +/// The frame under the preview audio's first sample (its clock before the device pulls any). +fn audio_frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> Option { + let app = h.state(); + let a = app.audio.as_ref()?; + Some(effectcraft_ui_egui::audio::frame_of_sample(a.clock(), a.rate, app.session.project.comp(cid).unwrap().frame_rate)) +} + +/// Step until the preview's sound starts (or 60 s pass, for a loaded machine), checking the +/// playhead holds `first` meanwhile. Returns whether the sound started. +fn until_sound(h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, first: i64) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); while std::time::Instant::now() < deadline { h.step(); if h.state().audio.is_some() { - break; + return true; } - let f = frame(&h); - assert!(f == last || f == last + 1 || f < last, "{last} → {f}: a frame was skipped"); - shown += usize::from(f != last); - last = f; + assert_eq!(frame(h, cid), first, "the playhead holds the first frame until the sound can start"); + assert!(h.state().playback.starting && h.state().playback.playing); std::thread::sleep(std::time::Duration::from_millis(1)); } - assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); - assert!(shown > 0, "frames played silently first"); + false +} + +/// Starting a preview with sound before its frames are cached (#947): the playhead holds the +/// first frame while the frames ahead render, then sound and picture start together from it (the +/// sound used to start at a later frame, losing the opening). A frame that isn't cached later on +/// still stops the sound rather than being skipped (#103). +#[test] +fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() { + let fake = Fake::default(); + let (mut app, cid) = setup(true, &fake); + // Several steps' worth of frames to cache before the sound can start (see the prefetch). + let ahead = (app.frames.parallelism() * 2).max(4); + app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app); + h.run_steps(2); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.starting && h.state().audio.is_none(), "nothing is cached: no sound yet"); + assert!(until_sound(&mut h, cid, 0), "the sound started once the frames ahead were cached"); + assert!(fake.0.lock().unwrap().0.is_some()); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame, not a later one"); + assert!(!h.state().playback.starting); // A frame that isn't cached stops the sound rather than being skipped. (Frames render fast // enough here to be cached again before the check: keep them out of the cache.) h.state().frames.set_budget(0); @@ -162,33 +170,106 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } -/// A preview whose frames render in real time sounds without waiting for the whole work area to -/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead -/// cached, the sound starts, still without skipping a frame before it. +/// A long (30 s) preview sounds once the frames ahead of its first frame are cached, not the +/// whole work area (#208), and from that first frame (#947). #[test] -fn preview_with_audio_sounds_once_rendering_keeps_up() { +fn preview_with_audio_sounds_without_caching_the_work_area() { let fake = Fake::default(); let (app, cid) = setup_long(true, &fake, 30.0); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); h.run_steps(2); - let frame = |h: &Harness<'_, EffectcraftApp>| { - let app = h.state(); - app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) - }; let now = h.ctx.input(|i| i.time); h.state_mut().play(now); - assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); - let mut last = frame(&h); - // Two seconds of playback, each step giving the frame workers time to keep up. - for _ in 0..60 { + assert!(until_sound(&mut h, cid, 0), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); +} + +/// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. +fn pull(feed: &AudioFeed, frames: usize) -> Vec { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while feed.queued_frames() < frames && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let mut out = vec![0.0f32; frames * 2]; + feed.pull(&mut out); + out +} + +/// The opening of the preview is heard (#947): a sound that lasts only the first 100 ms after the +/// start, from the beginning of the comp or from the middle, reaches the output device. +#[test] +fn a_sound_in_the_first_100_ms_is_heard() { + for start in [0.0, 1.0] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(false, &fake, 4.0); + // A tone only from `start` to `start` + 0.1 s. + let l = app.session.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + app.session.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + app.session.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + app.session.execute("layer.timing", json!({"layers": [l], "in": start, "out": start + 0.1})).unwrap(); + app.session.execute("time.set", json!({"time": start})).unwrap(); + let first = (start * 30.0) as i64; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(until_sound(&mut h, cid, first), "{start}: the sound started"); + assert_eq!(audio_frame(&h, cid), Some(first), "{start}: from the first frame"); + let feed = fake.0.lock().unwrap().0.clone().expect("the output opened"); + // The first 100 ms at 48 kHz. + let out = pull(&feed, 4800); + assert!(out.iter().any(|v| v.abs() > 0.01), "{start}: the opening tone reached the output"); + } +} + +/// A preview without video starts its sound at once, from the first frame (unchanged), and so +/// does one whose frames are already cached. +#[test] +fn audio_only_previews_start_at_once() { + let fake = Fake::default(); + let (app, cid) = setup(true, &fake); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app); + h.run_steps(2); + let now = h.ctx.input(|i| i.time); + h.state_mut().play_with(now, effectcraft_engine::preview::PreviewShortcut::AltNumpad0); + assert!(h.state().audio.is_some() && !h.state().playback.starting, "Video off: the sound starts at once"); + assert_eq!(audio_frame(&h, cid), Some(0)); +} + +/// Without an output device the preview still plays, from the first frame, once the frames ahead +/// are cached; Stop while it prepares leaves nothing playing. +#[test] +fn no_device_plays_the_picture_and_stop_ends_the_wait() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 10.0); + app.hooks.audio_device = Some(Box::new(|_| None)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.starting); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting && h.state().audio.is_none() && h.state().playback.playing, "no device: the picture plays alone"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { h.step(); - if h.state().audio.is_some() { - break; - } - let f = frame(&h); - assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); - last = f; - std::thread::sleep(std::time::Duration::from_millis(20)); - } - assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "from the first frame on"); + // Stop while preparing. + h.state_mut().stop(); + let now = h.ctx.input(|i| i.time); + h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(fake.clone()) as Box))); + h.state().frames.clear(); + h.state_mut().play(now); + assert!(h.state().playback.starting); + h.state_mut().stop(); + for _ in 0..10 { + h.step(); + } + assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays"); } From 682f38733d6c37196bd837cb74480797bdc0d551 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:38:45 -0400 Subject: [PATCH 0019/1386] Docs: gaps.md records the #947 preview startup tests --- docs/gaps.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index e621ee876..5ef6ab964 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -129,6 +129,12 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#947](https://github.com/storytold/effectcraft/issues/947): the + preview startup tests in `ui_audio_scrub`, checked headlessly on Windows with a fake output + device, start an audible preview on an uncached comp. The playhead holds the first frame until + the frames ahead are cached, the sound starts at that frame (from 0 s and from 1 s, also for a + 30 s work area), and a tone lasting only the first 100 ms reaches the output. Video off still + starts at once, no device plays the picture, and Stop while preparing leaves nothing playing. - Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They From e519f21f451b2ae31bc3b709e08d64bf224c6b79 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 22:39:25 -0400 Subject: [PATCH 0020/1386] Preview: the start with sound waits only for frames it can get (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start held the first frame until `ahead.min(fit)` frames were cached, which could never happen in two cases, holding it for ever: - the prefetch requests offsets 0..min(ahead * 3, fit - 1) from the playhead, one frame fewer than that when the RAM preview limits it, and with the playhead held nothing else requested the last one; - `fit` counts comp-sized frames, but Extended Viewer frames render larger, so fewer of them fit in the budget than it needs at once. It now waits for startup_frames(ahead, window, budget, frame bytes): ahead, no more than the prefetch window requests, and no more than fit in the budget at the size the first frame really rendered at, at least the current frame. Tests: startup_frames unit cases (a 4-frame budget needs 3; a 160×120 comp's 320×248 Extended Viewer frames need 2); ui_audio_scrub starts from a cold cache with budgets of 2, 3, 4, ahead - 1, ahead and ahead + 1 frames, with and without a device, and with Extended Viewer frames larger than the comp: each leaves its startup, from frame 0 (both hold the first frame for ever on the previous commit). Also: a device whose start fails plays the picture; a device that consumes the sound moves the picture on and a stall still stops it; the 30 s preview starts without caching the whole work area; Stop after a step of preparation never opens the output. --- crates/ui-egui/src/lib.rs | 46 ++++++- crates/ui-egui/tests/ui_audio_scrub.rs | 183 ++++++++++++++++++++++++- 2 files changed, 224 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb26641a3..85bc7a797 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,17 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0; /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// How many frames from the first a preview with sound waits for before it starts (#947): the +/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets +/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at +/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer +/// always requests. Waiting for a frame that is never requested, or that can't stay cached with +/// the others, would hold the first frame for ever. +fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 { + let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64; + ahead.min(window).min(fit).max(1) +} + /// Modal dialogs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dialog { @@ -1275,13 +1286,19 @@ impl EffectcraftApp { let fit = self.frames_that_fit(&c, scale); // Starting with sound: hold the first frame until the frames ahead are cached, then start // the sound from that same frame (see `Playback::starting`). Without an output device the - // picture plays on its own clock from it. + // picture plays on its own clock from it. How many frames ahead: only ones the prefetch + // above requests and that fit in the RAM preview at the size they really render (an + // Extended Viewer frame is larger than the comp), so the wait always ends. if self.playback.starting && let Some(p) = plan { self.playback.start_wall = now; self.playback.start_frame = cur; - if !self.cached_ahead(&p, &series, cur, ahead.min(fit)) { + let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| { + let [w, h] = img.size(); + startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) + }); + if !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { self.playback.waiting = true; ctx.request_repaint(); return; @@ -2175,3 +2192,28 @@ mod appearance_tests { assert_eq!(app.ui.theme, ThemeKind::Darker); } } + +#[cfg(test)] +mod startup_tests { + use super::startup_frames; + + /// The frames a preview with sound waits for are always requested and fit together (#947). + #[test] + fn startup_waits_only_for_frames_it_requests_and_can_keep() { + // A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch + // window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for. + let frame = 64 * 36 * 4; + let fit = 4; + let window = (32i64 * 3).min(fit - 1); + assert_eq!(startup_frames(32, window, 46_080, frame), 3); + // A roomy budget: the prefetch's `ahead` frames. + assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8); + // Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized + // frames but only 2 real ones (90 %): wait for 2, not 4. + assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2); + // Nothing fits, or no window: still the current frame, never 0. + assert_eq!(startup_frames(8, 0, 0, frame), 1); + // A zero frame size counts as 1 byte: no division by zero. + assert_eq!(startup_frames(8, 24, 10, 0), 8); + } +} diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index 2d256475d..94f3adad9 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -182,6 +182,8 @@ fn preview_with_audio_sounds_without_caching_the_work_area() { h.state_mut().play(now); assert!(until_sound(&mut h, cid, 0), "the sound started"); assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let cached = h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); + assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area"); } /// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. @@ -263,13 +265,188 @@ fn no_device_plays_the_picture_and_stop_ends_the_wait() { // Stop while preparing. h.state_mut().stop(); let now = h.ctx.input(|i| i.time); - h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(fake.clone()) as Box))); + let device = fake.clone(); + h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(device.clone()) as Box))); h.state().frames.clear(); h.state_mut().play(now); - assert!(h.state().playback.starting); + // One step into preparing: the frames ahead are requested and it waits for them. + h.step(); + assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing"); h.state_mut().stop(); - for _ in 0..10 { + // The renders it queued finish after Stop: they must not start the sound. + for _ in 0..30 { h.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays"); + assert!(fake.0.lock().unwrap().0.is_none(), "the output never opened"); +} + +/// Start a preview and step until it leaves its startup (or 60 s pass). Returns whether it did. +fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool { + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + !h.state().playback.starting +} + +/// RAM preview budgets just below, at and above the prefetch's frames ahead, and small ones, from +/// a cold cache, with and without an output device: the start never waits for a frame the +/// prefetch doesn't request (with a budget of 4 frames it asks for 3 ahead of the playhead). +#[test] +fn startup_finishes_at_any_ram_preview_budget() { + let probe = Fake::default(); + let (app, _) = setup(true, &probe); + let ahead = (app.frames.parallelism() * 2).max(4) as i64; + // A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9. + let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1]; + fits.sort(); + fits.dedup(); + for fit in fits { + for device in [true, false] { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + if !device { + app.hooks.audio_device = Some(Box::new(|_| None)); + } + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + let budget = ((fit as f64 + 0.5) * 9216.0 / 0.9) as usize; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + assert!(until_started(&mut h), "fit {fit} (ahead {ahead}), device {device}: the preview left its startup"); + assert_eq!(h.state().audio.is_some(), device, "fit {fit}, device {device}"); + if device { + assert_eq!(audio_frame(&h, cid), Some(0), "fit {fit}: from the first frame"); + } + } + } +} + +/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized +/// frames holds fewer of them, and the start waits only for as many as really fit (#947). +#[test] +fn startup_finishes_when_extended_viewer_frames_are_larger() { + let fake = Fake::default(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#4060a0"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap(); + s.prefs.three_d.extended_viewer = true; + let mut app = EffectcraftApp::new(s); + let f = fake.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box))); + let ahead = (app.frames.parallelism() * 2).max(4); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + // The viewer shows the pasteboard: frames render the extended region. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let size = loop { + h.step(); + let series = h.state().shown_series(cid); + if let Some(img) = h.state().frames.get(&series) { + break img.size(); + } + assert!(std::time::Instant::now() < deadline, "the first frame rendered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on"); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. + let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; + assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + assert!(until_started(&mut h), "the preview left its startup"); + assert!(h.state().audio.is_some(), "with its sound"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); +} + +/// An output device whose start fails. +#[derive(Clone, Default)] +struct Broken; + +impl AudioDevice for Broken { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, _feed: Arc) -> Result<(), String> { + Err("device unavailable".into()) + } + fn stop(&mut self) {} +} + +/// A device that fails to start: the picture plays on its own from the first frame. +#[test] +fn a_device_that_fails_to_start_plays_the_picture() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + app.hooks.audio_device = Some(Box::new(|_| Some(Box::new(Broken) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + assert!(until_started(&mut h), "it left its startup"); + assert!(h.state().audio.is_none() && h.state().playback.playing); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) == 0 && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(frame(&h, cid) > 0, "the picture plays"); +} + +/// An output that plays: a thread takes the feed's samples in real time, as a sound card would. +#[derive(Clone, Default)] +struct Playing(Arc>); + +impl AudioDevice for Playing { + fn sample_rate(&self) -> u32 { + 48000 + } + fn start(&mut self, feed: Arc) -> Result<(), String> { + let stop = self.0.clone(); + std::thread::spawn(move || { + let mut buf = vec![0.0f32; 480 * 2]; + while !*stop.lock().unwrap() { + feed.pull(&mut buf); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + Ok(()) + } + fn stop(&mut self) { + *self.0.lock().unwrap() = true; + } +} + +/// Once started, the sound's clock moves the picture on from the first frame, and a frame that +/// isn't cached still stops the sound rather than being skipped. +#[test] +fn a_started_preview_follows_its_sound() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 8.0); + let device = Playing::default(); + let d = device.clone(); + app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(d.clone()) as Box))); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + assert!(until_started(&mut h) && h.state().audio.is_some(), "the sound started"); + assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while frame(&h, cid) < 5 && std::time::Instant::now() < deadline && h.state().audio.is_some() { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(frame(&h, cid) >= 5, "the picture follows the sound's clock (at frame {})", frame(&h, cid)); + h.state().frames.set_budget(0); + h.state().frames.clear(); + h.step(); + assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound"); + *device.0.lock().unwrap() = true; } From 5586b40cf06303392dd9ecdecb569a56201b3461 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 0021/1386] #1190: clear existing STL Clippy warning --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From ab692dce702abaf1a0cc4d31fc1f0ae3044046b4 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 11:43:31 +0000 Subject: [PATCH 0022/1386] #1190: enable drag toggling for built-in checkboxes --- crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/panels/anim_tools.rs | 2 +- crates/ui-egui/src/panels/comp_settings.rs | 6 +- crates/ui-egui/src/panels/dialogs.rs | 8 +- crates/ui-egui/src/panels/dialogs_3d.rs | 6 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/essential.rs | 2 +- crates/ui-egui/src/panels/forms.rs | 16 +- crates/ui-egui/src/panels/key_dialogs.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 4 +- crates/ui-egui/src/panels/markers_ui.rs | 4 +- crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/paint_panels.rs | 6 +- crates/ui-egui/src/panels/panel_kit.rs | 2 +- crates/ui-egui/src/panels/path_vr_panels.rs | 2 +- crates/ui-egui/src/panels/precomp.rs | 4 +- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 22 +- crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/src/panels/tracker.rs | 14 +- crates/ui-egui/src/widgets.rs | 191 +++++++++++++++++- crates/ui-egui/tests/ui_checkbox_drag.rs | 54 +++++ 25 files changed, 306 insertions(+), 63 deletions(-) create mode 100644 crates/ui-egui/tests/ui_checkbox_drag.rs diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..571229007 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; - if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() { + if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).changed() { let _ = app.session.execute("view.snapping", serde_json::json!({})); app.ui.snapping = app.session.state.snapping; } @@ -591,7 +591,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim); x += 40.0; let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); - if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() { + if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).changed() { let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh})); } app.auto.add("header.puppet.showMesh", show, "Show mesh"); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f89..8d4066a7f 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -41,7 +41,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(auto, r, text); if clicked { !on } else { on } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..14b1e7a9c 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -321,7 +321,7 @@ pub fn show( let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); - let r = ui.checkbox(&mut d.lock_aspect, lbl); + let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32; @@ -416,11 +416,11 @@ pub fn show( }); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue")); auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue"); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested")); auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested"); ui.end_row(); ui.label(egui::RichText::new(tr("Motion Blur")).strong()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..b458701fc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -480,7 +480,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); - let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); + let r = crate::widgets::labelled_checkbox( + ui, + &mut lock, + tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]), + ); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); @@ -536,7 +540,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if editing.is_some() { ui.add_space(8.0); let r = ui - .add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) + .add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid"))) .on_disabled_hover_text(tr("Only this layer uses this solid.")); auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid"); if shared && !affect_all { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..84cf8d009 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -201,12 +201,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Depth of Field")); - ui.checkbox(&mut d.dof, tr("Enable Depth of Field")); + crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field")); ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); - ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); + crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); @@ -301,7 +301,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); } ui.label(tr("Shadows")); - ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); + crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..87bc006c3 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -404,7 +404,7 @@ fn prop_row( } Value::Bool(b) => { let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() { + if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).changed() { set(actions, json!(!b)); } p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 2e99c7d39..3dd9be36a 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -353,7 +353,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Checkbox => { let mut on = v.as_bool(); - let r = ui.checkbox(&mut on, ""); + let r = crate::widgets::labelled_checkbox(ui, &mut on, ""); if r.changed() { out = Some(json!(on)); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b75..b37351336 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -814,7 +814,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect .inner .rect } - FieldKind::Bool(b) => ui.checkbox(b, "").rect, + FieldKind::Bool(b) => crate::widgets::labelled_checkbox(ui, b, "").rect, FieldKind::Choice { options, sel } => { let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default(); // The column keeps its width: a long choice (a GPU adapter's name) ends in an @@ -944,13 +944,13 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke let mut close = false; super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| { let v = &mut app.ui.viewer; - ui.checkbox(&mut v.show_layer_controls, tr("Layer controls")); - ui.checkbox(&mut v.show_masks, tr("Masks")); - ui.checkbox(&mut v.rulers, tr("Rulers")); - ui.checkbox(&mut v.guides, tr("Guides")); - ui.checkbox(&mut v.grid, tr("Grid")); - ui.checkbox(&mut v.safe_margins, tr("Title/action safe")); - ui.checkbox(&mut v.transparency_grid, tr("Transparency grid")); + crate::widgets::labelled_checkbox(ui, &mut v.show_layer_controls, tr("Layer controls")); + crate::widgets::labelled_checkbox(ui, &mut v.show_masks, tr("Masks")); + crate::widgets::labelled_checkbox(ui, &mut v.rulers, tr("Rulers")); + crate::widgets::labelled_checkbox(ui, &mut v.guides, tr("Guides")); + crate::widgets::labelled_checkbox(ui, &mut v.grid, tr("Grid")); + crate::widgets::labelled_checkbox(ui, &mut v.safe_margins, tr("Title/action safe")); + crate::widgets::labelled_checkbox(ui, &mut v.transparency_grid, tr("Transparency grid")); ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 73f76aaaa..56fd5185a 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -160,7 +160,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); } - let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)")); app.auto.add("dialog.velocity.continuous", r.rect, "Continuous"); if d.continuous { d.out_speed = d.in_speed.clone(); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..b3b78fb57 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -86,7 +86,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { match (v, &p.ui) { (KV::Bool(b), _) => { let mut on = *b; - ui.checkbox(&mut on, "").changed().then(|| json!(on)) + crate::widgets::labelled_checkbox(ui, &mut on, "").changed().then(|| json!(on)) } (KV::Enum(i), ParamUi::Popup { options }) => { let mut sel = *i as usize; @@ -170,7 +170,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if id != BLENDING { let g = styles.as_ref().and_then(|s| s.sub(id)); let mut on = g.is_some_and(|g| g.enabled); - if ui.checkbox(&mut on, "").changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, "").changed() { if g.is_none() { actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id}))); } else { diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 2fff7e1c8..102309765 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.cue, tr("Cue Point")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point")); app.auto.add("dialog.marker.cue", r.rect, "Cue Point"); ui.add_enabled_ui(d.cue, |ui| { ui.horizontal(|ui| { @@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.add_space(8.0); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.protected, tr("Protected Region")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region")); app.auto.add("dialog.marker.protected", r.rect, "Protected Region"); ui.add_space(20.0); ui.label(tr("Label:")); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..5a1b77c07 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -240,7 +240,7 @@ fn fmt_rate(r: f64) -> String { fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).changed(); p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); app.auto.add(&format!("preview.{key}"), r, text); clicked diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 675bc1e16..3c9332aea 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -210,7 +210,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() { + if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).changed() { set(app, json!({k: !on})); } p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); @@ -237,7 +237,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 26.0; let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() { + if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).changed() { set(app, json!({"showOverlay": !o.show_overlay})); } app.auto.add("paint.showOverlay", r, "Clone Source Overlay"); @@ -246,7 +246,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set(app, json!({"overlayOpacity": nv})); } let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0)); - if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() { + if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).changed() { set(app, json!({"overlayDifference": !o.overlay_difference})); } app.auto.add("paint.overlayDifference", dr, "Difference mode overlay"); diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs index cbf28c8ac..06dc8b4db 100644 --- a/crates/ui-egui/src/panels/panel_kit.rs +++ b/crates/ui-egui/src/panels/panel_kit.rs @@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); - let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked(); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).changed(); ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0)); app.auto.add(auto, full, text); diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs index 9dba0dd59..b9d747a3b 100644 --- a/crates/ui-egui/src/panels/path_vr_panels.rs +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) })); } } - let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)")); + let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)")); app.auto.add("createNulls.loop", r.rect, "Loop"); ui.data_mut(|d| d.insert_temp(loop_id, looping)); run(app, &ctx, acts); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index 8a40c56e9..f6d3068b1 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ); ui.add_space(4.0); ui.add_enabled_ui(!d.leave, |ui| { - let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers")); app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.open, tr("Open New Composition")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition")); app.auto.add("dialog.precompose.open", r.rect, "Open New Composition"); ui.add_space(14.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..62938e635 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -1094,7 +1094,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if crate::widgets::labelled_checkbox(ui, &mut on, label).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..9939b56fa 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -402,7 +402,7 @@ fn text_section( for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let ry = y + i as f32 * 24.0; let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() { + if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).changed() { let k = if key == "fill" { "applyFill" } else { "applyStroke" }; set(actions, json!({k: !on})); } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..539fb2af1 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -463,7 +463,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Notify (sound + notification when the queue finishes). let notify = app.session.project.render_prefs.notify; let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).changed() { actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); } p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); @@ -589,7 +589,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&aid("twirl"), tw, "Twirl"); let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); - if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + if ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id)))).inner.changed() && !rendering { actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); } app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..77eb7e7c0 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -106,7 +106,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, tr(label)); + let r = crate::widgets::labelled_checkbox(ui, &mut b, tr(label)); if r.changed() { acts.push(Act::Set(key.into(), json!(b))); } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..fd5e4e17b 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -188,7 +188,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() { let fx = x0 + i as f32 * (w / 2.0); let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key)))).inner.changed() && enabled { actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on})); } app.auto.add(&format!("character.{key}.enabled"), cr, label); @@ -240,7 +240,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += 32.0; // Ligatures. let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures"))).inner.changed() && enabled { actions.push(json!({"ligatures": !doc.ligatures})); } app.auto.add("character.ligatures", lr, "Ligatures"); @@ -264,7 +264,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key)))).inner.changed() && enabled { actions.push(json!({key: !on})); } app.auto.add(&format!("character.{key}"), cr, label); @@ -544,7 +544,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 30.0; let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0)); - if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled { + if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging"))).inner.changed() && enabled { actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation})); } app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation"); @@ -629,17 +629,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: ui.set_width(rect.width().max(180.0)); // Filter row: a checkbox that keeps only the variable fonts. let filter_h = 22.0; - let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, "")); - let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h)); - let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max); - let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check")); - ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text); + let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only"))); app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); - let toggled = row.clicked() || boxed.clicked(); + let toggled = row.changed(); if toggled { - only_variable = !only_variable; ui.data_mut(|d| d.insert_temp(variable_id, only_variable)); - // Typing goes on into the search field. + } + // The native checkbox can take focus on release after changing on press. + // Typing goes on into the search field after either event. + if toggled || row.clicked() { ui.memory_mut(|m| m.request_focus(search_id)); } let query = state.query.trim().to_lowercase(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..79bfe7174 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2146,7 +2146,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); - if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { + if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).changed() { actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); } // Track matte: layer picker + kind. @@ -2476,7 +2476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]}))); } let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).clicked() { + if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).changed() { actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted}))); } lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim); @@ -4098,7 +4098,7 @@ fn value_editor( } Value::Bool(b) => { let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).clicked() { + if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).changed() { set(actions, json!(!b)); } app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..aa537ff8c 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -246,10 +246,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)), ] { let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0)); - let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key))); + let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key)))).inner; app.auto.add(&format!("tracker.{key}"), cr, text); p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint }); - if resp.clicked() && transformish { + if resp.changed() && transformish { run(app, &ctx, "track.setType", with(json!({ key: !on }))); } xx += 84.0; @@ -506,20 +506,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.add_space(8.0); ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5))); ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); let r = ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); - let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); }); ui.add_space(8.0); - let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning")); app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning"); - let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame")); app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame"); - let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); + let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale")); app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale"); ui.add_space(8.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..1ab352730 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -382,9 +382,96 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token resp } -/// Checkbox in the AE look (small square). +/// A primary-button gesture shared by custom and labelled checkboxes in this context. +#[derive(Clone, Default)] +struct CheckboxDrag { + press_frame: u64, + target: Option, + visited: std::collections::HashSet, +} + +/// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are +/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. +fn drag_toggle(ui: &Ui, response: &Response, value: Option) -> Option { + let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released())); + let frame = ui.ctx().cumulative_frame_nr(); + // clicked() reads Context input; do not call it while Context data is locked. + let clicked = response.clicked(); + ui.data_mut(|data| { + let drag = data.get_temp_mut_or_default::(egui::Id::new("checkbox-drag")); + if (!down && !released) || (pressed && drag.press_frame != frame) { + *drag = CheckboxDrag { press_frame: frame, ..Default::default() }; + } + let on = value?; + if !response.enabled() { + return None; + } + if pressed && drag.target.is_none() && response.is_pointer_button_down_on() && response.contains_pointer() { + drag.target = Some(!on); + } + if down + && response.contains_pointer() + && let Some(target) = drag.target + { + if drag.visited.insert(response.id) && on != target { + return Some(target); + } + return None; + } + // The source already changed on press. Suppress its ordinary release click. + if released && drag.target.is_some() { + return None; + } + clicked.then_some(!on) + }) +} + +/// A labelled checkbox with the same drag behavior as the custom panel checkbox. +/// Use through `ui.add`, `ui.put`, or `ui.add_enabled` like `egui::Checkbox`. +pub struct DragCheckbox<'a> { + value: &'a mut bool, + text: egui::WidgetText, +} + +impl<'a> DragCheckbox<'a> { + pub fn new(value: &'a mut bool, text: impl Into) -> Self { + Self { value, text: text.into() } + } +} + +impl egui::Widget for DragCheckbox<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let before = *self.value; + let mut response = ui.add(egui::Checkbox::new(self.value, self.text)); + // egui changes on release; our shared gesture changes on press and entry. + *self.value = before; + response.flags.remove(egui::response::Flags::CHANGED); + if response.clicked() { + ui.ctx().request_repaint(); + } + if let Some(value) = drag_toggle(ui, &response, Some(before)) { + *self.value = value; + response.mark_changed(); + ui.ctx().request_repaint(); + } + response + } +} + +/// A labelled checkbox that allocates its own rectangle. +pub fn labelled_checkbox(ui: &mut Ui, value: &mut bool, text: impl Into) -> Response { + ui.add(DragCheckbox::new(value, text)) +} + +/// Checkbox in the AE look (small square). `changed()` requests a toggle, including +/// keyboard activation and entry during a drag begun on another checkbox. pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> Response { - let resp = ui.interact(rect, id, Sense::click()); + let mut resp = ui.interact(rect, id, Sense::click()); + let next = drag_toggle(ui, &resp, Some(on)); + if next.is_some() { + resp.mark_changed(); + } + let on = next.unwrap_or(on); let b = Rect::from_center_size(rect.center(), vec2(12.0, 12.0)); ui.painter().rect_filled(b, 2.0, if on { t.accent } else { t.field_bg }); ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, if on { t.accent } else { t.field_border }), StrokeKind::Inside); @@ -666,6 +753,106 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + fn checkbox_frame(ctx: &egui::Context, values: &mut [Option; 5], events: Vec) { + let t = Tokens::for_kind(Default::default()); + let input = egui::RawInput { events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + for (index, value) in values.iter_mut().enumerate() { + let rect = Rect::from_min_size(pos2(20.0, 20.0 + index as f32 * 30.0), vec2(120.0, 20.0)); + ui.add_enabled_ui(index != 3, |ui| { + if index == 4 { + if let Some(on) = value { + ui.put(rect, DragCheckbox::new(on, crate::i18n::tr("Grid"))); + } + } else if let Some(on) = value { + if checkbox(ui, rect, *on, &t, egui::Id::new(index)).changed() { + *on = !*on; + } + } else { + let response = ui.interact(rect, egui::Id::new(index), Sense::click()); + assert_eq!(drag_toggle(ui, &response, None), None); + } + }); + } + }); + output.textures_delta.clear(); + } + + fn checkbox_pointer(y: f32, pressed: Option) -> Vec { + let pos = pos2(28.0, y); + let mut events = vec![egui::Event::PointerMoved(pos)]; + if let Some(pressed) = pressed { + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + events + } + + #[test] + fn checkbox_drag_sets_one_state_skips_mixed_and_disabled_and_ends_on_release() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(true), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(true))); + assert_eq!(values[0], Some(true)); + // An already-matching neighbour must not invert, even after revisiting. + for y in [58.0, 88.0, 118.0, 148.0, 58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + assert_eq!(values, [Some(true), Some(true), None, Some(false), Some(true)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(true), "release must not toggle the source again"); + // Begin on the labelled wrapper and drag back into custom checkboxes. + checkbox_frame(&ctx, &mut values, checkbox_pointer(148.0, Some(true))); + assert_eq!(values[4], Some(false)); + for y in [58.0, 28.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None)); + } + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)]); + checkbox_frame(&ctx, &mut values, checkbox_pointer(58.0, None)); + assert_eq!(values[1], Some(false), "hover after release must not paint"); + } + + #[test] + fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() { + let ctx = egui::Context::default(); + let mut values = [Some(false), Some(false), None, Some(false), Some(false)]; + checkbox_frame(&ctx, &mut values, vec![]); + // Pressing outside the checkboxes must not start a gesture on entry. + checkbox_frame(&ctx, &mut values, checkbox_pointer(200.0, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false)); + for source_y in [88.0, 118.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(source_y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None)); + checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false))); + assert_eq!(values[0], Some(false), "mixed and disabled sources cannot begin a drag"); + } + for y in [28.0, 148.0] { + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(true))); + checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(false))); + } + assert_eq!(values[0], Some(true)); + assert_eq!(values[4], Some(true)); + ctx.memory_mut(|m| m.request_focus(egui::Id::new(0usize))); + checkbox_frame( + &ctx, + &mut values, + vec![egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert_eq!(values[0], Some(false), "keyboard activation still works"); + // Fast clicks may deliver press and release together in one input frame. + // This also exercises clicked() without an active drag (outside the data lock). + for (index, y) in [(0, 28.0), (4, 148.0)] { + let before = values[index]; + let mut events = checkbox_pointer(y, Some(true)); + events.extend(checkbox_pointer(y, Some(false))); + checkbox_frame(&ctx, &mut values, events); + assert_eq!(values[index], before.map(|on| !on)); + } + } + /// #488: an inline editor selects its whole text only as it opens. Should it be without the /// keyboard for a frame later (an input method, another handler), it takes the keyboard back /// with the caret where the user put it instead of selecting everything again, which made diff --git a/crates/ui-egui/tests/ui_checkbox_drag.rs b/crates/ui-egui/tests/ui_checkbox_drag.rs new file mode 100644 index 000000000..ba65e54dc --- /dev/null +++ b/crates/ui-egui/tests/ui_checkbox_drag.rs @@ -0,0 +1,54 @@ +//! #1190: the shared drag gesture also reaches real dialog controls. +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable as _}; +use serde_json::json; + +fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option) { + h.event(Event::PointerMoved(at)); + h.step(); + if let Some(pressed) = pressed { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +#[test] +fn view_options_drag_checks_and_unchecks_neighbours() { + let mut session = Session::default(); + session.prefs.disk.disk_cache_enabled = false; + session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.state_mut().ui.viewer.rulers = false; + h.state_mut().ui.viewer.guides = true; + h.state_mut().ui.viewer.grid = false; + h.run_steps(3); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap(); + h.run_steps(3); + let rulers = h.get_by_label("Rulers").rect().center(); + let guides = h.get_by_label("Guides").rect().center(); + let grid = h.get_by_label("Grid").rect().center(); + pointer(&mut h, rulers, Some(true)); + assert!(h.state().ui.viewer.rulers); + pointer(&mut h, guides, None); + assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked"); + pointer(&mut h, grid, None); + assert!(h.state().ui.viewer.grid); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change"); + if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") { + h.render().unwrap().save(path).unwrap(); + } + pointer(&mut h, grid, Some(true)); + pointer(&mut h, guides, None); + pointer(&mut h, rulers, None); + pointer(&mut h, rulers, Some(false)); + let viewer = &h.state().ui.viewer; + assert!(!viewer.rulers && !viewer.guides && !viewer.grid); + pointer(&mut h, guides, None); + assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox"); +} From c75ea46c8647459f4c7e186be878904286523244 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 07:56:29 -0400 Subject: [PATCH 0023/1386] Preview: the start with sound never waits for ever (#947) The startup hold waited for the current frame to be cached before anything else, so a frame that can never be cached held the first frame for ever: a zero RAM preview budget, or a single frame larger than the budget (which the comp-size estimate misses for Extended Viewer frames). It now gives up at once when the budget can't hold even one comp-sized frame, and otherwise after STARTUP_WAIT (2 s from pressing play): the sound starts from the first frame, and the preview goes on as a stalled one does. frame_bytes() is factored out of frames_that_fit() for it. Tests (ui_audio_scrub): with a budget of 0 or half a frame the sound starts on the first step; with an Extended Viewer frame larger than the whole budget (the estimate says one fits) the first frame holds for about two seconds, then the sound starts. Both wait for ever on the previous commit. The Extended Viewer setup is now shared by its two tests. --- crates/ui-egui/src/lib.rs | 22 +++++++-- crates/ui-egui/tests/ui_audio_scrub.rs | 66 +++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 85bc7a797..16630ed23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,12 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0; /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; +/// Longest a preview with sound holds its first frame waiting for frames (seconds, #947). A frame +/// that never gets cached (larger than the RAM preview, which the size estimate can't always +/// tell) mustn't hold it for ever: the sound starts from the first frame anyway, and the preview +/// goes on as a stalled one does. +const STARTUP_WAIT: f64 = 2.0; + /// How many frames from the first a preview with sound waits for before it starts (#947): the /// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets /// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at @@ -234,6 +240,8 @@ pub struct Playback { /// while they rendered and the sound started at the later frame, so the opening was lost. /// A stall later on still falls back to [`Playback::audio_held`]. pub starting: bool, + /// When the preview started (seconds): [`Playback::starting`] gives up after [`STARTUP_WAIT`]. + pub starting_since: f64, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } @@ -1022,6 +1030,7 @@ impl EffectcraftApp { audio_frame: (!plan.video).then_some(plan.first), audio_held: false, starting: false, + starting_since: now, shown_at: Default::default(), }; if !self.playback.caching { @@ -1298,7 +1307,10 @@ impl EffectcraftApp { let [w, h] = img.size(); startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) }); - if !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { + // Don't wait for frames the RAM preview can't hold (not even one frame fits), nor for + // longer than STARTUP_WAIT (a frame that never gets cached). + let give_up = (self.frames.budget() as f64) < self.frame_bytes(&c, scale) || now - self.playback.starting_since >= STARTUP_WAIT; + if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { self.playback.waiting = true; ctx.request_repaint(); return; @@ -1467,9 +1479,13 @@ impl EffectcraftApp { } fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 { + ((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1) + } + + /// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger). + fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 { let side = |n: u32| (n as f64 * scale).ceil().max(1.0); - let frame = side(comp.width) * side(comp.height) * 4.0; - ((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1) + side(comp.width) * side(comp.height) * 4.0 } fn frames_parallelism(&self) -> usize { diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index 94f3adad9..c01b6bb2d 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -327,11 +327,10 @@ fn startup_finishes_at_any_ram_preview_budget() { } } -/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized -/// frames holds fewer of them, and the start waits only for as many as really fit (#947). -#[test] -fn startup_finishes_when_extended_viewer_frames_are_larger() { - let fake = Fake::default(); +/// An audible 160×120 comp with a 3D layer and Draft 3D, Extended Viewer on: its viewer frames +/// render the pasteboard too, larger than the comp. Returns the harness once the first frame has +/// rendered, the comp, and that frame's size. +fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [usize; 2]) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap(); let cid = s.active_comp_id().unwrap(); @@ -344,7 +343,6 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() { let mut app = EffectcraftApp::new(s); let f = fake.clone(); app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box))); - let ahead = (app.frames.parallelism() * 2).max(4); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); // The viewer shows the pasteboard: frames render the extended region. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); @@ -358,6 +356,16 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() { std::thread::sleep(std::time::Duration::from_millis(5)); }; assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on"); + (h, cid, size) +} + +/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized +/// frames holds fewer of them, and the start waits only for as many as really fit (#947). +#[test] +fn startup_finishes_when_extended_viewer_frames_are_larger() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let ahead = (h.state().frames.parallelism() * 2).max(4); let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); // Room for `ahead` comp-sized frames (and a half), which is fewer real ones. let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize; @@ -450,3 +458,49 @@ fn a_started_preview_follows_its_sound() { assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound"); *device.0.lock().unwrap() = true; } + +/// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait +/// for frames that can never be cached; the sound starts at once from the first frame. +#[test] +fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { + for budget in [0, 9216 / 2] { + let fake = Fake::default(); + let (app, _) = setup_long(true, &fake, 8.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + h.step(); + assert!(!h.state().playback.starting, "budget {budget}: no wait"); + assert!(fake.0.lock().unwrap().0.is_some(), "budget {budget}: the sound started"); + assert!(h.state().playback.playing); + } +} + +/// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame +/// larger than the whole budget): the start gives up after a short wait instead of holding the +/// first frame for ever, and the sound starts from it. +#[test] +fn startup_gives_up_after_a_short_wait() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + // Room for a comp-sized frame but not a real one: the current frame can never be cached. + let budget = (comp_bytes + real_bytes) / 2; + h.state().frames.set_budget(budget); + h.state().frames.clear(); + let start = h.ctx.input(|i| i.time); + h.state_mut().play(start); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + assert_eq!(frame(&h, cid), 0, "it holds the first frame while it waits"); + h.step(); + } + let waited = h.ctx.input(|i| i.time) - start; + assert!(!h.state().playback.starting, "it gave up waiting"); + assert!((1.9..4.0).contains(&waited), "after about two seconds ({waited:.2} s)"); + assert!(fake.0.lock().unwrap().0.is_some(), "and the sound started"); +} From d7dfe018cc97c834847ca52b67cf87fe0d9f9185 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:34:22 +0200 Subject: [PATCH 0024/1386] docs: re-verify against 0.9.2 - verified: 0.9.2 on every page, checked against the v0.9.2 tag and release assets - install, price-and-platforms: Windows x86 builds, riscv64 deb/rpm, macOS CLI zip - gpu-not-used: GPU Information now names the adapter or why the GPU is off; OpenGL means CPU compositing; #613 still open - import-footage: TGA, HDR, DPX, Cineon sequences; dropped PSDs ask; drop on an empty viewer makes a comp - render-a-video: QuickTime (Uncompressed) format - make-a-composition: Layer > New > Text starts typing; save and delete presets - CLAUDE.md: regular file instead of a symlink Co-Authored-By: Claude Opus 5.5 --- docs/user/CLAUDE.md | 2 +- docs/user/faq/after-effects-projects.md | 2 +- docs/user/faq/price-and-platforms.md | 16 +++++----- docs/user/getting-started/install.md | 10 ++++-- docs/user/guides/add-an-effect.md | 2 +- docs/user/guides/animate-with-keyframes.md | 2 +- docs/user/guides/import-footage.md | 8 ++--- docs/user/guides/make-a-composition.md | 6 ++-- docs/user/guides/render-a-video.md | 33 ++++++++++---------- docs/user/index.md | 2 +- docs/user/troubleshooting/footage-missing.md | 2 +- docs/user/troubleshooting/gpu-not-used.md | 8 +++-- 12 files changed, 51 insertions(+), 42 deletions(-) mode change 120000 => 100644 docs/user/CLAUDE.md diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/docs/user/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/after-effects-projects.md b/docs/user/faq/after-effects-projects.md index 2c22e6644..792aa7cc1 100644 --- a/docs/user/faq/after-effects-projects.md +++ b/docs/user/faq/after-effects-projects.md @@ -2,7 +2,7 @@ title: Can EffectCraft open After Effects projects? description: EffectCraft cannot open .aep or .aepx files, and After Effects plug-ins do not run. It saves .ecproj files and imports Lottie. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- No. EffectCraft cannot open After Effects projects (`.aep` or `.aepx`), and After Effects plug-ins do not run in it. diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md index 546d3a3d3..40f93c60d 100644 --- a/docs/user/faq/price-and-platforms.md +++ b/docs/user/faq/price-and-platforms.md @@ -2,20 +2,20 @@ title: Is EffectCraft free, and where does it run? description: EffectCraft is free and open source under MIT or Apache-2.0, and runs on Windows, macOS, Linux and in the browser. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2.0, at your option. It has no telemetry, and it uses no network access unless you ask for it. ## Where it runs -| Platform | Builds | -|----------|------------------------------------------------------------------------------| -| Windows | x64 and arm64, as MSI installers and portable zips | -| macOS | One universal app for Apple silicon and Intel | -| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | -| FreeBSD | x86_64 tar.gz | -| Browser | A web build that runs the full app | +| Platform | Builds | +|----------|----------------------------------------------------------------------------------------| +| Windows | x64, arm64 and 32-bit x86, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 deb, rpm, tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | See [Install EffectCraft](../getting-started/install.md) for the files. diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 605fce2be..b2e77a5d9 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -2,10 +2,10 @@ title: Install EffectCraft description: Download EffectCraft for Windows 11, macOS or Linux, install it, or install it through ArtCraft Launcher. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- -The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.8.0`. +The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.9.2`. ## Windows 11 @@ -18,11 +18,15 @@ The release also has an `arm64` installer for Snapdragon and other ARM PCs. The To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` build has a portable zip too. +There are also 32-bit `x86` builds, as an installer and a portable zip. + ## macOS 1. Download `effectcraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs and is signed and notarized. 2. Open it and drag EffectCraft to Applications. +The command-line tool is a separate download for macOS: `effectcraft-cli--macos-universal.zip`. + ## Linux Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. @@ -35,7 +39,7 @@ Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. | `effectcraft--linux-x86_64.flatpak` | `flatpak install --user ` | | `effectcraft--linux-x86_64.tar.gz` | Unpack it anywhere. | -There is also a RISC-V 64-bit `.tar.gz` (it needs glibc 2.39 or newer) and a FreeBSD x86_64 `.tar.gz`. +For RISC-V 64-bit there is a `.deb`, an `.rpm` and a `.tar.gz` (`riscv64` in the file name). They need glibc 2.39 or newer. There is also a FreeBSD x86_64 `.tar.gz`. ## In the browser diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md index f6e4d53ba..5459b21d9 100644 --- a/docs/user/guides/add-an-effect.md +++ b/docs/user/guides/add-an-effect.md @@ -2,7 +2,7 @@ title: Add an effect description: Apply an effect such as Gaussian Blur to a layer and change its settings in Effect Controls. order: 30 -verified: 0.8.0 +verified: 0.9.2 --- Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md index ceaa526f5..53ef7882e 100644 --- a/docs/user/guides/animate-with-keyframes.md +++ b/docs/user/guides/animate-with-keyframes.md @@ -2,7 +2,7 @@ title: Animate with keyframes description: Fly a layer in with two Position keyframes and smooth the motion with Easy Ease. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Click the stopwatch beside a property to start animating it, move the current-time indicator, then change the value. The second keyframe appears by itself. Select both keyframes and press F9 to ease the motion. diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md index 5792e961b..2cd754bd2 100644 --- a/docs/user/guides/import-footage.md +++ b/docs/user/guides/import-footage.md @@ -2,7 +2,7 @@ title: Import footage and image sequences description: Bring video, images and numbered image sequences into an EffectCraft project, and change how footage is read. order: 50 -verified: 0.8.0 +verified: 0.9.2 --- Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. @@ -13,9 +13,9 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option in 1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. 2. Photoshop and PDF or Illustrator files first ask whether to import them as footage or as a composition. -3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. +3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. If no composition is open, dropping it on the empty viewer makes a new composition from it. -You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. +You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. A dropped Photoshop file asks how to import it, as **File ▸ Import ▸ File...** does. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. ## Import an image sequence @@ -27,7 +27,7 @@ You can also drop files or folders on the window. Files dropped on the Compositi **Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. -Stills in PNG, JPEG, TIFF, OpenEXR, BMP and WebP can form a sequence. PSD, SVG and PDF files import one by one. +Stills in PNG, JPEG, TIFF, OpenEXR, BMP, WebP, Targa (`.tga`), Radiance HDR (`.hdr`), DPX and Cineon (`.cin`) can form a sequence. PSD, SVG and PDF files import one by one. If the numbering skips a frame, EffectCraft shows colour bars for it. Put the missing files in the folder and choose **File ▸ Reload Footage** to fill the gap. diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md index 9b32b3d41..5dcd8702b 100644 --- a/docs/user/guides/make-a-composition.md +++ b/docs/user/guides/make-a-composition.md @@ -2,7 +2,7 @@ title: Make your first composition description: Create a composition, add a text layer and save the project as an .ecproj file. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- Choose **Composition ▸ New Composition...**, set the size, frame rate and duration, click **OK**, then add layers from the **Layer ▸ New** menu. Shortcuts below use Ctrl on Windows and Linux. On macOS, use Cmd. @@ -13,7 +13,7 @@ Choose **Composition ▸ New Composition...**, set the size, frame rate and dura 2. In the dialog, type a **Composition Name**. The default is `Comp 1`. 3. Pick a **Preset**, or set **Width**, **Height**, **Frame Rate** and **Duration**. The presets include `HDTV 1080 24`, `UHD 4K 25` and `Social Media Square · 1080x1080 · 30 fps`. 4. Click **OK**. The composition opens in the Composition panel and the Timeline. -5. Choose **Layer ▸ New ▸ Text**. A text layer appears at the top of the Timeline. +5. Choose **Layer ▸ New ▸ Text** and type your text. A text layer appears at the top of the Timeline, and its name follows what you type. 6. Add more layers from **Layer ▸ New**: **Solid...**, **Shape Layer**, **Null Object**, **Adjustment Layer**, **Light...** and **Camera...**. 7. Choose **File ▸ Save** to save the project. @@ -23,6 +23,8 @@ Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. +To keep a size and frame rate for later, click the **+** button next to **Preset** (**Save Preset…**) and name it. Your presets are listed above the built-in ones, in this dialog and in **New Composition...**. To remove one, select it and click the bin button (**Delete Preset**). + ## Learn in the app The in-app tutorial **Animate a title** walks through a similar title animation. To open it, choose **Help ▸ In-App Tutorials...**. diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md index 35c8a1c92..42fa5d0a5 100644 --- a/docs/user/guides/render-a-video.md +++ b/docs/user/guides/render-a-video.md @@ -2,7 +2,7 @@ title: Render a video description: Add a composition to the Render Queue, choose a format such as H.264, pick where to save and click Render. order: 40 -verified: 0.8.0 +verified: 0.9.2 --- Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. @@ -25,22 +25,23 @@ Tick **Notify** to get a sound and a notification when the queue finishes. The **Output Module:** menu lists the formats your build can export: -| Menu entry | File type | -|-----------------------|-------------------------------------| -| H.264 | `.mp4` | -| QuickTime (ProRes) | `.mov` | -| HEVC (H.265) | `.mp4` | -| AV1 | `.mp4` | -| WebM (VP9 + Opus) | `.webm` | -| PNG Sequence | `.png`, one file per frame | -| JPEG Sequence | `.jpg`, one file per frame | -| TIFF Sequence | `.tif`, one file per frame | -| OpenEXR Sequence | `.exr`, one file per frame | -| Animated GIF | `.gif` | -| WAV | `.wav`, audio only | -| AIFF | `.aif`, audio only | +| Menu entry | File type | +|--------------------------|-----------------------------| +| H.264 | `.mp4` | +| QuickTime (ProRes) | `.mov` | +| QuickTime (Uncompressed) | `.mov` | +| HEVC (H.265) | `.mp4` | +| AV1 | `.mp4` | +| WebM (VP9 + Opus) | `.webm` | +| PNG Sequence | `.png`, one file per frame | +| JPEG Sequence | `.jpg`, one file per frame | +| TIFF Sequence | `.tif`, one file per frame | +| OpenEXR Sequence | `.exr`, one file per frame | +| Animated GIF | `.gif` | +| WAV | `.wav`, audio only | +| AIFF | `.aif`, audio only | -To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. +To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes), QuickTime (Uncompressed) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. ## Render only part of a composition diff --git a/docs/user/index.md b/docs/user/index.md index babeaf8bb..05ed4993c 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -1,7 +1,7 @@ --- title: EffectCraft description: EffectCraft is a free, open-source app for motion graphics and visual effects, an alternative to Adobe After Effects. -verified: 0.8.0 +verified: 0.9.2 --- EffectCraft is a free, open-source app for animated titles, motion graphics and compositing. It is an alternative to Adobe After Effects. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md index 9e859c636..7507a2ad0 100644 --- a/docs/user/troubleshooting/footage-missing.md +++ b/docs/user/troubleshooting/footage-missing.md @@ -2,7 +2,7 @@ title: Footage is missing or offline description: Find missing footage in an EffectCraft project, relink it to a folder or replace it with another file. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md index 0c56840db..7c4fb29e0 100644 --- a/docs/user/troubleshooting/gpu-not-used.md +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -2,7 +2,7 @@ title: EffectCraft does not use my graphics card description: What to do when the report says GPU none (CPU compositing), the window picked another adapter, or GPU effects fail on Windows 11 with DirectX 12. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences (**Edit ▸ Preferences ▸ Startup & Repair...**; on macOS: **EffectCraft ▸ Settings... ▸ Startup & Repair...**) and restart EffectCraft. @@ -23,15 +23,17 @@ Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Win One user whose GPU was not detected on 0.8.0 fixed it this way: they set the window graphics to Automatic, and EffectCraft started through Vulkan ([issue #596](https://github.com/storytold/effectcraft/issues/596)). +If the window runs on your card but the report still says `GPU: none (CPU compositing)`, the reason after it says why the GPU compositor did not start. With **OpenGL (compatibility)**, compositing always runs on the CPU, because the GPU compositor needs DirectX 12, Vulkan, Metal or WebGPU. + ## Why this happens When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL by itself. That keeps the app usable. Setting **Window Graphics** to **Automatic** lets EffectCraft choose the graphics backend again. -The **GPU Information...** button on the Previews page of the preferences shows fixed text in 0.8.0. It does not name your GPU. Use the System Compatibility Report instead. +The **GPU Information...** button on the Previews page of the preferences names the adapter the GPU compositor runs on. When compositing runs on the CPU, it says why after `GPU compositing is off:`. It also shows the `Window graphics:` line. For a bug report, still send the whole System Compatibility Report. ## Windows 11 with DirectX 12 -On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is open as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. +On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is still open in 0.9.2 as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. The window can also take a long time to appear. On 0.8.0 with an RTX 5060 Ti, one user measured 69 seconds before the window showed on DirectX 12 without a working DXC shader compiler, against 3 seconds on Vulkan, which is what Automatic picked on that PC. If you close EffectCraft during that wait, the next launch uses OpenGL and has no GPU compositor until you set **Window Graphics** back to **Automatic** (steps above). So wait for the window on the first launch. From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 0025/1386] Render: drop the blanket layer cover from effect stacks; effects pay only what they need (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #659 every text or shape layer with an enabled video effect grew its effect buffer to the comp-sized layer rectangle before the stack ran, so glow and blur — which only need their own margins — allocated a 1920x1080 RGBA32F texture per layer per frame in flight (~5 GB peak VRAM in parallel export, measured on an RTX 3090). Effects that actually place pixels outside the painted content already grow the buffer themselves by exactly what they need (CC Tiler, CC Smear, CC Mosaic sharp via layer_cover_pad / Buf::pad), on CPU and GPU. The blanket target.cover(layer_size) in Renderer::run_effects_on, the FxTarget::cover trait method with its CPU default, and the GPU GpuTarget::cover texture allocation are removed; Buf::cover/cover_plan stay as the effects-crate helpers they were designed as. The #659 regression tests (tiling across the layer region, centred-shape edge pinning) and the render/effects/gpu suites pass unchanged. --- crates/gpu/src/effects.rs | 15 --------------- crates/render/src/lib.rs | 24 +++++------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..426931d78 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,21 +145,6 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } - - /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no - /// readback. A region the device can't allocate leaves the buffer as it is. - fn cover(&mut self, size: [f64; 2]) { - let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } - } - _ => b, - }; - self.b = Some(out); - } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..d9c87af9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,16 +490,6 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); - /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own - /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. - /// Authored buffers (text, shapes) only span their painted content; this gives the stack - /// the room a sized source's buffer already has (#659). - fn cover(&mut self, size: [f64; 2]) { - self.cpu(&mut |mut buf| { - buf.cover(size); - buf - }); - } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1132,15 +1122,11 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); - } + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From e043fc0bb02b18086d92a4d521d17d3a3cff61b3 Mon Sep 17 00:00:00 2001 From: nanox333 Date: Tue, 13 Oct 2026 15:47:05 +0200 Subject: [PATCH 0026/1386] #712: color pickers get an eyedropper everywhere The Character panel's pipette (#938) only sampled text. Every color picker now has an eyedropper in its hex row: click it, then click in the Composition panel to take the color under the pointer. The picker is one shared popup, so Solid Settings, Fill and Stroke, attached images, effects and the rest get it without caller changes. - color_picker: the eyedropper arms the picker. Escape or a second click disarms it and the picker stays open. While armed, the viewer shows a crosshair, a "Click to sample color" chip and a swatch under the pointer, and the next press on the viewer samples into the picker. A press over the picker or a dialog is not a sample, and a press elsewhere closes the picker as before. - The Character panel's pipette glyph moves to color_picker::draw_pipette, so both show the same icon. - Automation id colorPicker.eyedropper. - Modal dialogs (modal_fit) and the picker's popup register their rects as overlays, so a press on them never samples the viewer beneath. Not compiled, tested or rustfmt-checked: the machine it was written on has no Rust toolchain. Run cargo xtask ci before relying on it. Pushed to the fork for testing only. --- crates/ui-egui/src/color_picker.rs | 233 ++++++++++++++++++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 2 + crates/ui-egui/src/panels/effect_controls.rs | 26 +++ crates/ui-egui/src/panels/fx_widgets.rs | 7 +- crates/ui-egui/src/panels/text_panels.rs | 3 +- 6 files changed, 261 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index 35d8e4eb4..a692d78db 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -1,6 +1,7 @@ //! The colour picker every colour input opens, laid out like After Effects' Color Picker: a //! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new -//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel. +//! colour over the original, the HSB and RGB values, the hex code with an eyedropper that samples +//! the Composition viewer, and OK / Cancel. //! //! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made //! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with. @@ -30,29 +31,49 @@ pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { } /// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true -/// when the colour changed (Cancel, Escape and the original swatch change it back). +/// when the colour changed (Cancel, Escape and the original swatch change it back; the eyedropper +/// changes it to the colour it samples). pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { if !popup_is_open(ui, id) { return false; } let orig_id = id.with("original"); let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c)); + // Read before a sample is taken: the press that samples the viewer lands outside the picker, and + // must not close it. + let armed = armed_for(ui.ctx(), id); + let mut sampled = false; + if let Some(rgb) = take_sample(ui.ctx(), id) + && rgb != *c + { + *c = rgb; + sampled = true; + } let mut out = Outcome::default(); let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); }); - // Escape is the picker's: consume it, so nothing later this pass takes it too. + // Escape is the picker's: consume it, so nothing later this pass takes it too. While the + // eyedropper is armed, Escape cancels it and the picker stays open. if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - out.close = Some(false); + if armed { + disarm(ui.ctx(), id); + } else { + out.close = Some(false); + } } - if pressed_outside(ui.ctx(), &area.response) { + // A press outside closes the picker, unless it is the armed eyedropper's press on the viewer. + let on_viewer = armed && eyedropper_point(ui.ctx()).is_some(); + if pressed_outside(ui.ctx(), &area.response) && !on_viewer { out.close.get_or_insert(true); } + note_overlay(ui.ctx(), area.response.rect); if let Some(keep) = out.close { if !keep && *c != original { *c = original; out.changed = true; } + disarm(ui.ctx(), id); ui.data_mut(|d| { d.insert_temp(id.with("open"), false); d.remove::<[f32; 3]>(orig_id); @@ -60,11 +81,12 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) d.remove::(open_pass_id()); }); } else { + refresh_armed(ui.ctx(), id); // Still open after this pass: dialogs leave Escape to the picker (see `open_recently`). let pass = ui.ctx().cumulative_pass_nr(); ui.data_mut(|d| d.insert_temp(open_pass_id(), pass)); } - out.changed + out.changed || sampled } fn open_pass_id() -> egui::Id { @@ -80,6 +102,177 @@ pub fn open_recently(ctx: &egui::Context) -> bool { pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr()) } +/// The armed eyedropper of a picker: the picker's popup id, the pass it was last drawn in, and the +/// colour the viewer sampled for it, if one is waiting for the picker's next draw. It lives in the +/// egui context, since the viewer that samples has no picker in hand. +#[derive(Clone, Copy)] +struct Armed { + popup: egui::Id, + pass: u64, + sample: Option<[f32; 3]>, +} + +fn armed_key() -> egui::Id { + egui::Id::new("color-picker-eyedropper") +} + +/// The armed eyedropper, if its picker was drawn in this pass or the last: one whose picker stops +/// being drawn (its dialog gone) lapses. +fn armed_record(ctx: &egui::Context) -> Option { + let armed: Option = ctx.data(|d| d.get_temp(armed_key())); + let pass = ctx.cumulative_pass_nr(); + armed.filter(|a| a.pass.saturating_add(1) >= pass) +} + +fn armed_for(ctx: &egui::Context, popup: egui::Id) -> bool { + armed_record(ctx).is_some_and(|a| a.popup == popup) +} + +/// Some picker's eyedropper is armed: a press on the Composition viewer samples the frame for it. +pub fn eyedropper_armed(ctx: &egui::Context) -> bool { + armed_record(ctx).is_some() +} + +fn arm(ctx: &egui::Context, popup: egui::Id) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { popup, pass, sample: None })); +} + +fn disarm(ctx: &egui::Context, popup: egui::Id) { + let armed: Option = ctx.data(|d| d.get_temp(armed_key())); + if armed.is_some_and(|a| a.popup == popup) { + ctx.data_mut(|d| d.remove::(armed_key())); + } +} + +/// Keeps `popup`'s eyedropper armed while its picker is drawn. +fn refresh_armed(ctx: &egui::Context, popup: egui::Id) { + if let Some(armed) = armed_record(ctx).filter(|a| a.popup == popup) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { pass, ..armed })); + } +} + +/// The colour the viewer sampled for `popup`, once: taking it disarms the eyedropper. +fn take_sample(ctx: &egui::Context, popup: egui::Id) -> Option<[f32; 3]> { + let armed = armed_record(ctx).filter(|a| a.popup == popup)?; + let sample = armed.sample?; + disarm(ctx, popup); + Some(sample) +} + +/// The viewer sampled `rgb` for the armed picker, which takes it on its next draw. +pub fn deliver_sample(ctx: &egui::Context, rgb: [f32; 3]) { + if let Some(armed) = armed_record(ctx) { + ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { sample: Some(rgb), ..armed })); + ctx.request_repaint(); + } +} + +fn viewer_key() -> egui::Id { + egui::Id::new("color-picker-viewer") +} + +/// The Composition viewer's rect as it was drawn this pass: where an armed eyedropper samples. +pub fn note_viewer(ctx: &egui::Context, rect: Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(viewer_key(), (pass, rect))); +} + +fn overlay_key() -> egui::Id { + egui::Id::new("color-picker-overlays") +} + +/// A popup or dialog drawn over the viewer: a press inside `rect` is not a sample. +pub fn note_overlay(ctx: &egui::Context, rect: Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + // This pass's and the last's: the viewer may be drawn before an overlay in a pass. + let mut rects = d.get_temp::>(overlay_key()).unwrap_or_default(); + rects.retain(|(p, _)| p.saturating_add(1) >= pass); + rects.push((pass, rect)); + d.insert_temp(overlay_key(), rects); + }); +} + +/// Whether `pos` is over a popup or dialog drawn this pass or the last. +fn overlaid(ctx: &egui::Context, pos: egui::Pos2) -> bool { + let pass = ctx.cumulative_pass_nr(); + let rects: Option> = ctx.data(|d| d.get_temp(overlay_key())); + rects.unwrap_or_default().iter().any(|(p, r)| p.saturating_add(1) >= pass && r.contains(pos)) +} + +/// Where an armed eyedropper samples: the pointer, if it is over the viewer and not over a popup or +/// dialog. The pointer is read rather than the viewer's responses, since a modal dialog over the +/// viewer takes its clicks. +pub fn eyedropper_point(ctx: &egui::Context) -> Option { + let viewer: Option<(u64, Rect)> = ctx.data(|d| d.get_temp(viewer_key())); + let viewer = viewer.filter(|(p, _)| p.saturating_add(1) >= ctx.cumulative_pass_nr())?.1; + let pos = ctx.input(|i| i.pointer.interact_pos())?; + (viewer.contains(pos) && !overlaid(ctx, pos)).then_some(pos) +} + +/// The press that samples for an armed eyedropper this frame, if there is one. +pub fn sample_press(ctx: &egui::Context) -> Option { + if ctx.input(|i| i.pointer.primary_pressed()) { eyedropper_point(ctx) } else { None } +} + +fn automation_key() -> egui::Id { + egui::Id::new("color-picker-eyedropper-rect") +} + +/// The eyedropper's rect, for the automation registry; [`take_automation`] hands it over. +fn note_automation(ctx: &egui::Context, rect: Rect) { + ctx.data_mut(|d| d.insert_temp(automation_key(), rect)); +} + +/// The eyedropper rect the last picker drawing noted, once. Pickers have no registry in hand, so the +/// app registers it each frame. +pub fn take_automation(ctx: &egui::Context) -> Option { + ctx.data_mut(|d| { + let rect = d.get_temp::(automation_key()); + d.remove::(automation_key()); + rect + }) +} + +/// The eyedropper of the hex row. A press arms it, or disarms it while armed; the next press on the +/// Composition viewer then samples the colour under the pointer into this picker. +fn eyedropper(ui: &mut Ui, popup: egui::Id) { + let armed = armed_for(ui.ctx(), popup); + let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + let resp = resp.on_hover_text(tr("Eyedropper: click, then click in the Composition panel")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Eyedropper")); + let fill = if armed { + ui.visuals().selection.bg_fill + } else if resp.hovered() { + ui.visuals().widgets.hovered.bg_fill + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, 2.0, fill); + let col = if armed { ui.visuals().selection.stroke.color } else { ui.visuals().strong_text_color() }; + draw_pipette(ui.painter(), rect, col); + if resp.clicked() { + if armed { + disarm(ui.ctx(), popup); + } else { + arm(ui.ctx(), popup); + } + } + note_automation(ui.ctx(), rect); +} + +/// A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left, drawn +/// within `rect`. +pub fn draw_pipette(p: &egui::Painter, rect: Rect, col: Color32) { + let r = rect.shrink(rect.width() * 0.2); + let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); + p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col)); + p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col); + p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col)); +} + /// What the picker did this frame. #[derive(Default)] struct Outcome { @@ -237,6 +430,7 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) if hex_field(ui, id.with("hex"), &mut rgb) { pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb }; } + eyedropper(ui, id); }); }); }); @@ -429,3 +623,30 @@ pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { }; [f(5.0), f(3.0), f(1.0)] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn armed_eyedropper_takes_one_sample_then_disarms() { + let ctx = egui::Context::default(); + let popup = egui::Id::new("test-picker"); + arm(&ctx, popup); + assert!(eyedropper_armed(&ctx)); + assert_eq!(take_sample(&ctx, popup), None); + deliver_sample(&ctx, [0.1, 0.2, 0.3]); + assert_eq!(take_sample(&ctx, popup), Some([0.1, 0.2, 0.3])); + assert!(!eyedropper_armed(&ctx)); + assert_eq!(take_sample(&ctx, popup), None); + } + + #[test] + fn points_over_a_dialog_are_not_samples() { + let ctx = egui::Context::default(); + let dialog = Rect::from_min_size(pos2(100.0, 100.0), vec2(120.0, 80.0)); + note_overlay(&ctx, dialog); + assert!(overlaid(&ctx, pos2(150.0, 150.0))); + assert!(!overlaid(&ctx, pos2(20.0, 20.0))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..67fa405f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1614,6 +1614,9 @@ impl EffectcraftApp { self.apply_gpu_failure(&ctx); self.run_dialog_answers(); self.auto.begin_frame(); + if let Some(rect) = crate::color_picker::take_automation(&ctx) { + self.auto.add("colorPicker.eyedropper", rect, "Eyedropper"); + } self.frames.set_context(&ctx); if self.session.render_job.is_some() { self.session.poll_render(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..2ed25070e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -213,6 +213,8 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & ui.add_space(10.0); let title_h = ui.cursor().top() - ui.min_rect().top(); body(ui, (size.y - 36.0 - title_h).max(40.0)); + // A colour picker's eyedropper samples the viewer under the dialog, but not the dialog itself. + crate::color_picker::note_overlay(ui.ctx(), ui.min_rect().expand(18.0)); }); } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..b42047c75 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1455,6 +1455,7 @@ pub fn viewer_hook( ) { let t = app.tokens; let ctx = ui.ctx().clone(); + crate::color_picker::note_viewer(&ctx, map.area); let mut actions: Actions = vec![]; let mut point_preview = None; if let Some(pick) = app.ui.fx_pick.clone() { @@ -1574,6 +1575,31 @@ pub fn viewer_hook( } } } + // A colour picker's eyedropper is armed (its popup stays open): the viewer shows the colour under + // the pointer, and the next press on it samples the frame into the picker. The press is read from + // the pointer, since a modal dialog over the viewer takes its clicks. + if crate::color_picker::eyedropper_armed(&ctx) { + let _ = ui.interact(map.area, egui::Id::new("viewer-popup-eyedropper"), Sense::click()); + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + app.viewer_pixels(); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, "Click to sample color", Tokens::ui(11.5), t.text); + if let Some(hp) = crate::color_picker::eyedropper_point(&ctx) + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(hp)) + { + let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + if let Some(pos) = crate::color_picker::sample_press(&ctx) + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + crate::color_picker::deliver_sample(&ctx, [s[0], s[1], s[2]]); + } + } let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index ca49c3fd0..bce037a10 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -171,12 +171,7 @@ pub fn eyedropper_button(ui: &mut egui::Ui, rect: Rect, active: bool, id: egui:: } else { t.text_dim }; - // A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left. - let r = rect.shrink(rect.width() * 0.2); - let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); - p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col)); - p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col); - p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col)); + crate::color_picker::draw_pipette(p, rect, col); resp } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..ed5c531cb 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -204,7 +204,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0)); let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key); - let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t); + let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t) + .on_hover_text(tr("Eyedropper: click, then click in the Composition panel")); app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper")); if eye.clicked() { if active { From 9a202b133ae15a232090e2468143a351e8c1e7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:10 +0200 Subject: [PATCH 0027/1386] Render: effect stacks keep the cover's pixel-grid phase, only known-margin stacks skip the cover, and displacement and Repeat Edge Pixels get the room the cover gave them (#1205) --- crates/effects/src/distort2.rs | 3 ++ crates/effects/src/lib.rs | 53 ++++++++++++++++++++++++++++++++++ crates/effects/src/misc.rs | 6 ++++ crates/gpu/src/effects.rs | 46 +++++++++++++++++++++++++++++ crates/gpu/src/fx_distort.rs | 3 ++ crates/render/src/lib.rs | 38 +++++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..4f09b95e2 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -93,6 +93,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..a968f1aad 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -286,6 +286,32 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + if left == 0 && top == 0 { + return; + } + let src = std::mem::replace(&mut self.img, Image::new(0, 0)); + let (w, h) = (src.width + left, src.height + top); + let mut out = Image::new(w, h); + let sw = src.width as usize; + for y in 0..src.height as usize { + let d = (y + top as usize) * w as usize + left as usize; + out.data[d..d + sw].copy_from_slice(&src.data[y * sw..(y + 1) * sw]); + } + self.img = out; + self.offset[0] += left as f64; + self.offset[1] += top as f64; + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { @@ -301,6 +327,33 @@ impl Buf { /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer /// already covers that region unchanged (footage, solids, the comp below an adjustment). +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { return None; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..fed4ce45c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); b @@ -49,6 +52,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((r * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d78..4bf503940 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -23,6 +23,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -145,6 +159,32 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { @@ -249,6 +289,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -264,6 +307,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index a62ad6831..e4e17c64c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -177,6 +177,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9c87af9f..041052b8e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,6 +490,24 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1126,7 +1144,25 @@ impl<'a> Renderer<'a> { // place pixels outside it grow the buffer themselves, by exactly what they need // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, - // per frame in flight, for glow and blur that never use the extra room. + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { + Some(effect.as_str()) + } + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + target.align(layer_size); + } else { + target.cover(layer_size); + } + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From f51b4b7e1be5310fab102a8f4d507b33775fdadd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 10:52:40 -0400 Subject: [PATCH 0028/1386] Track Matte: set the matte of several layers at once (#1260) layer.setTrackMatte took one layer. It now also takes `layers`, set in one undo step; `layer` and the first selected layer still work as before. `kind: "preserve"` keeps each layer's own matte mode (Alpha for a layer without one), so giving several layers a new matte doesn't turn a mix of Alpha and Luma mattes into one mode; without `kind` the mode is Alpha, as before. A layer can't be its own matte and an empty list is refused, before anything changes. Test: several_layers_take_one_matte_and_keep_their_modes. --- crates/engine/src/commands/layer.rs | 89 ++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca8aa865d..4ccfbe3e6 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -551,32 +551,47 @@ fn blend(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer) use `matte` as +/// their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps +/// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers +/// at once keeps their modes. fn track_matte(s: &mut Session, p: &Value) -> Result { - let (cid, lid) = layer_p(s, p, "layer.setTrackMatte")?; + let (cid, ids) = match p.get("layers") { + Some(_) => layers_p(s, p)?, + None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?, + }; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad("layer.setTrackMatte", "no such matte layer"))?), }; - let kind = str_p(p, "kind") - .map(|k| match k.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + if ids.is_empty() { + return Err(bad("layer.setTrackMatte", "no layers")); + } + if matte.is_some_and(|m| ids.contains(&m)) { + return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); + } + // None: each layer keeps its own mode. + let kind = match str_p(p, "kind").map(|k| k.to_ascii_lowercase().replace([' ', '_', '-'], "")) { + Some(k) if k == "preserve" => None, + Some(k) => Some(match k.as_str() { "alphainverted" | "alphainv" => MatteKind::AlphaInverted, "luma" => MatteKind::Luma, "lumainverted" | "lumainv" => MatteKind::LumaInverted, _ => MatteKind::Alpha, - }) - .unwrap_or(MatteKind::Alpha); + }), + None => Some(MatteKind::Alpha), + }; s.edit("Track Matte", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - if let Some(m) = matte { - if m == lid { - return Err(bad("layer.setTrackMatte", "a layer can't be its own matte")); - } - if let Some(ml) = comp.layer_mut(m) { - ml.switches.video = false; - } + if let Some(ml) = matte.and_then(|m| comp.layer_mut(m)) { + ml.switches.video = false; + } + for lid in &ids { + let l = comp.layer_mut(*lid).ok_or(EngineError::NoComp)?; + let kind = kind.unwrap_or_else(|| l.track_matte.map(|m| m.kind).unwrap_or_default()); + l.track_matte = matte.map(|layer| TrackMatte { layer, kind }); } - comp.layer_mut(lid).ok_or(EngineError::NoComp)?.track_matte = matte.map(|layer| TrackMatte { layer, kind }); Ok(()) })?; Ok(Value::Null) @@ -1656,7 +1671,7 @@ pub fn specs() -> Vec { "Track Matte", [], None, - "{layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted}", + "{layers? | layer?, matte: layer|null, kind?: alpha|alphaInverted|luma|lumaInverted|preserve}", has_layers, track_matte ), @@ -1862,3 +1877,49 @@ mod parent_chain_tests { assert!(check_parent_chain(&comp, &[LayerId(c)], Some(LayerId(10_000)), 4096, 1_048_576).is_ok()); } } + +#[cfg(test)] +mod track_matte_batch_tests { + use super::*; + use std::sync::Arc; + + fn matte_of(s: &Session, id: u64) -> Option<(u64, MatteKind)> { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) + } + + /// #1260: several layers take one matte layer at once, in one undo step; `kind: "preserve"` + /// keeps each one's mode; refused calls change nothing; one `layer` still works. + #[test] + fn several_layers_take_one_matte_and_keep_their_modes() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Mattes","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, c, x, y] = ["A", "B", "C", "X", "Y"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": b, "matte": x})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (Some((x, MatteKind::Luma)), Some((x, MatteKind::Alpha))), "one layer, as before"); + let (before, undo) = (s.project.clone(), s.history.undo.len()); + s.execute("layer.setTrackMatte", json!({"layers": [a, b, c], "matte": y, "kind": "preserve"})).unwrap(); + assert_eq!( + [matte_of(&s, a), matte_of(&s, b), matte_of(&s, c)], + [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha))], + "each keeps its mode (Alpha without one)" + ); + assert!(!s.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "the matte is hidden"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); + // Refused: a layer as its own matte, an unknown layer, no layers. Nothing changes. + for p in [ + json!({"layers": [a, y], "matte": y}), + json!({"layers": [a, 9999], "matte": y}), + json!({"layers": [], "matte": y}), + json!({"layers": [a], "matte": 9999}), + ] { + let (project, revision) = (s.project.clone(), s.revision); + assert!(s.execute("layer.setTrackMatte", p.clone()).is_err(), "{p}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{p} changed nothing"); + } + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap(); + assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it"); + } +} From 4ce95694549f4e7f4be3d79c814776cc53fce0c4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 10:52:40 -0400 Subject: [PATCH 0029/1386] Timeline: parent and matte selected layers together (#1260) On a selected layer's row, the Parent menu and pick whip and the Track Matte menu now set every selected layer's parent or matte, as moving a selected layer's bar moves them all; on an unselected row they set that layer's alone. Each is one undo step. A selected layer chosen as the parent or the matte is left out of the layers it links, and parenting that would make a loop is refused as a whole. A new matte layer keeps each layer's mode (layer.setTrackMatte with kind: preserve); a mode and No Track Matte apply to all of them. The Track Matte column gets a pick whip, like the parent's (timeline.layer..trackMattePickWhip, with a translated tooltip): dropped on an audio-video layer's row, it makes that layer the matte. The layers a parent or matte pick whip links are taken when its drag starts. Tests (ui_batch_links): the Parent menu and pick whip, and the Track Matte menu, mode, No Track Matte and pick whip, with real clicks and drags: selected layers change together and unselected ones don't, modes are kept, one undo puts links, compensated transforms and the matte's visibility back, and a refused cycle changes nothing. Both fail on main. --- crates/ui-egui/src/i18n/ui.rs | 7 ++ crates/ui-egui/src/panels/timeline.rs | 81 +++++++++---- crates/ui-egui/tests/ui_batch_links.rs | 154 +++++++++++++++++++++++++ docs/agents.md | 7 ++ 4 files changed, 228 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/tests/ui_batch_links.rs diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..d56e0de77 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -383,6 +383,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Overlay", "オーバーレイ"), ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), + ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), @@ -924,6 +925,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Overlay", "叠加"), ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), + ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), @@ -1656,6 +1658,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Overlay", "疊加"), ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), + ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), @@ -2397,6 +2400,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Overlay", "Накладання"), ("Parameter Name:", "Назва параметра:"), ("Parent pick whip: drag onto a layer", "Зв’язування батьківського шару: перетягніть на шар"), + ("Track Matte pick whip: drag onto a layer", "Зв’язування трекової маски: перетягніть на шар"), ("Pixel Aspect Ratio:", "Пропорції пікселя:"), ("Playing on Video Preview", "Відтворення на відеоперегляді"), ("Post-Render Action: {}", "Дія після рендерингу: {}"), @@ -2942,6 +2946,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Output To:", "Saída para:"), ("Parameter Name:", "Nome do parâmetro:"), ("Parent pick whip: drag onto a layer", "Pick whip de pai: arraste até uma camada"), + ("Track Matte pick whip: drag onto a layer", "Pick whip de track matte: arraste até uma camada"), ("Pixel Aspect Ratio:", "Proporção de pixel:"), ("Playing on Video Preview", "Reproduzindo na visualização de vídeo"), ("Post-Render Action: {}", "Ação pós-renderização: {}"), @@ -3393,6 +3398,7 @@ const CZECH: &[(&str, &str)] = &[ ("Overlay", "Překrytí"), ("Parameter Name:", "Název parametru:"), ("Parent pick whip: drag onto a layer", "Propojovací nástroj nadřazené vrstvy: přetáhněte na vrstvu"), + ("Track Matte pick whip: drag onto a layer", "Propojovací nástroj masky stopy: přetáhněte na vrstvu"), ("Pixel Aspect Ratio:", "Poměr stran pixelů:"), ("Playing on Video Preview", "Přehrávání ve videonáhledu"), ("Post-Render Action: {}", "Akce po vykreslení: {}"), @@ -3949,6 +3955,7 @@ const SPANISH: &[(&str, &str)] = &[ ("Overlay", "Superposición"), ("Parameter Name:", "Nombre del parámetro:"), ("Parent pick whip: drag onto a layer", "Selector de capa padre: arrastra hasta una capa"), + ("Track Matte pick whip: drag onto a layer", "Selector de mate de seguimiento: arrastra hasta una capa"), ("Pixel Aspect Ratio:", "Relación de aspecto de píxel:"), ("Playing on Video Preview", "Reproduciendo en la previsualización de vídeo"), ("Post-Render Action: {}", "Acción posterior al procesamiento: {}"), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..f2d91fa3d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -155,16 +155,29 @@ fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, } /// A pick-whip drag in progress: (kind, layer, prop uid, start point). The kinds are the -/// layer's parent pick whip, an expression's and a property's (Parent & Link column). +/// layer's parent pick whip, an expression's, a property's (Parent & Link column) and the layer's +/// track matte pick whip. type PickWhip = (u8, u64, u64, Pos2); const WHIP_PARENT: u8 = 0; const WHIP_EXPR: u8 = 1; const WHIP_PROP: u8 = 2; +const WHIP_MATTE: u8 = 3; fn pick_whip_id() -> egui::Id { egui::Id::new("tl-pickwhip") } +/// The layers a layer pick whip (parent, track matte) links, taken as its drag starts. +fn whip_layers_id() -> egui::Id { + egui::Id::new("tl-pickwhip-layers") +} + +/// The layers an edit made on `layer`'s row applies to (its parent, its track matte, moving its +/// bar): the selection when the row is selected, else that layer alone (#1260). +fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { + if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } +} + /// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for /// the layer's own), drawn and dropped at the end of [`show`]. #[allow(clippy::too_many_arguments)] @@ -183,7 +196,11 @@ fn pick_whip( app.auto.add(auto, rect, label); if resp.drag_started() { let state: PickWhip = (kind, layer, prop, rect.center()); - ui.ctx().data_mut(|d| d.insert_temp(pick_whip_id(), state)); + let layers = edited_layers(LayerId(layer), &app.session.state.selected_layers); + ui.ctx().data_mut(|d| { + d.insert_temp(pick_whip_id(), state); + d.insert_temp(whip_layers_id(), layers); + }); } resp } @@ -2149,8 +2166,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); } - // Track matte: layer picker + kind. - let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); + // Track matte: a pick whip, then the layer picker with the mode. Like the parent, + // they set the matte of every selected layer when the row is selected (#1260). + pick_whip( + app, + ui, + &lp, + Rect::from_center_size(pos2(cw.trkmat + 9.0, cy), vec2(15.0, 15.0)), + (WHIP_MATTE, layer.id.0, 0), + &format!("timeline.layer.{}.trackMattePickWhip", layer.id.0), + "Track Matte pick whip", + crate::i18n::tr("Track Matte pick whip: drag onto a layer"), + ); + let mr = Rect::from_min_size(pos2(cw.trkmat + 20.0, cy - 9.0), vec2(88.0, 18.0)); let label = match layer.track_matte { Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()), None => "No Track Matte".into(), @@ -2180,7 +2208,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { let ids: Vec = candidates.iter().map(|l| l.id).collect(); - if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) { + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&edited_layers(layer.id, &selected), &ids, i, o)) { actions.push(a); } } @@ -2218,8 +2246,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { - let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; - actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); + let par = cands.get(i.wrapping_sub(2)).map(|l| l.id.0); + // (The new parent itself, if selected, stays as it is.) + let layers: Vec = edited_layers(layer.id, &selected).into_iter().filter(|l| Some(*l) != par).collect(); + actions.push(("layer.setParent".into(), json!({"layers": layers, "parent": par}))); } } // Layer bar. @@ -2353,7 +2383,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let d = frames as f64 * fd; // Moving or trimming a selected layer acts on the whole // selection; each layer trims from its own in/out point. - let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) }; + let targets = json!(edited_layers(layer.id, &selected)); let params = match kind { "move" => json!({"layers": targets, "delta": d, "merge": drag_key}), "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}), @@ -3016,7 +3046,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); // Shown this pass or the last one (Effect Controls may draw after the Timeline). let pass = ctx.cumulative_pass_nr(); - let elsewhere = (target.is_none() && kind != WHIP_PARENT) + let elsewhere = (target.is_none() && !matches!(kind, WHIP_PARENT | WHIP_MATTE)) .then(|| ctx.data(|d| d.get_temp::(whip_targets_id()))) .flatten() .filter(|(p, _)| p.saturating_add(1) >= pass) @@ -3027,10 +3057,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { fg.rect_stroke(*tr, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); } if ctx.input(|i| !i.pointer.any_down()) { - ctx.data_mut(|d| { + let whip_layers = ctx.data_mut(|d| { d.remove::(pick_whip_id()); d.remove::(whip_targets_id()); + d.remove_temp::>(whip_layers_id()) }); + // The layers a layer pick whip links, less the one it was dropped on. + let linked = |onto: u64| -> Vec { whip_layers.clone().unwrap_or_else(|| vec![src_layer]).into_iter().filter(|l| *l != onto).collect() }; let into = (kind == WHIP_EXPR).then(|| ctx.data_mut(|d| d.remove_temp::(whip_into_id(src_prop)))).flatten(); // The property dropped on, and the value (dimension) if on one of them: `position[0]`. let dim = |fields: &[Rect]| fields.iter().position(|r| r.contains(ptr)); @@ -3043,7 +3076,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; match (kind, target, prop_target) { (WHIP_PARENT, Some((_, row)), _) if row.layer.0 != src_layer => { - actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + actions.push(("layer.setParent".into(), json!({"layers": linked(row.layer.0), "parent": row.layer.0}))); + } + // Each layer keeps its own matte mode. + (WHIP_MATTE, Some((_, row)), _) if row.layer.0 != src_layer && comp.layer(row.layer).is_some_and(|l| l.source.is_av()) => { + actions.push(("layer.setTrackMatte".into(), json!({"layers": linked(row.layer.0), "matte": row.layer.0, "kind": "preserve"}))); } // The property pick whip replaces the expression with the reference (or adds one), // as the expression pick whip does when its expression isn't being edited. @@ -3756,19 +3793,21 @@ fn text_anim_popups( } } -/// The command for the Track Matte menu item `i` (`choice`) of `layer`, whose menu lists "No Track -/// Matte", a separator, the matte `candidates`, a separator and the modes. A mode without a matte -/// layer takes the layer above, like Layer ▸ Track Matte (#864: it used to do nothing). -fn track_matte_pick(layer: &Layer, candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { +/// The command for the Track Matte menu item `i` (`choice`) of a row, setting `layers`' mattes, +/// whose menu lists "No Track Matte", a separator, the matte `candidates`, a separator and the +/// modes. A new matte layer keeps each layer's mode (the matte itself, if among them, is left +/// out); a mode without a matte layer takes the layer above, like Layer ▸ Track Matte (#864: it +/// used to do nothing). +fn track_matte_pick(layers: &[u64], candidates: &[LayerId], i: usize, choice: &str) -> Option<(String, serde_json::Value)> { if i == 0 { - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": null}))); + return Some(("layer.setTrackMatte".into(), json!({"layers": layers, "matte": null}))); } if let Some(m) = i.checked_sub(2).and_then(|j| candidates.get(j)) { - let kind = layer.track_matte.map(|m| m.kind).unwrap_or_default(); - return Some(("layer.setTrackMatte".into(), json!({"layer": layer.id.0, "matte": m.0, "kind": kind_name(kind)}))); + let layers: Vec = layers.iter().copied().filter(|l| *l != m.0).collect(); + return (!layers.is_empty()).then(|| ("layer.setTrackMatte".into(), json!({"layers": layers, "matte": m.0, "kind": "preserve"}))); } let k = MatteKind::ALL.into_iter().find(|k| k.label() == choice)?; - Some(("layer.trackMatte".into(), json!({"layers": [layer.id.0], "op": kind_name(k)}))) + Some(("layer.trackMatte".into(), json!({"layers": layers, "op": kind_name(k)}))) } fn kind_name(k: MatteKind) -> &'static str { @@ -4221,7 +4260,7 @@ mod tests { let (bottom, matte, top) = (find(&s, "Chain Bottom"), find(&s, "Matte"), find(&s, "Chain Top")); let candidates = [top.id, matte.id]; let pick = |s: &mut effectcraft_engine::Session, name: &str, i: usize, choice: &str| { - let (id, params) = track_matte_pick(&find(s, name), &candidates, i, choice).expect("a menu item is an action"); + let (id, params) = track_matte_pick(&[find(s, name).id.0], &candidates, i, choice).expect("a menu item is an action"); s.execute(&id, params) }; pick(&mut s, "Chain Bottom", 5, "Alpha Inverted Matte").unwrap(); @@ -4239,7 +4278,7 @@ mod tests { assert!(pick(&mut s, "Chain Top", 5, "Alpha Matte").is_err()); pick(&mut s, "Chain Bottom", 0, "No Track Matte").unwrap(); assert!(find(&s, "Chain Bottom").track_matte.is_none()); - assert!(track_matte_pick(&bottom, &candidates, 1, "-").is_none(), "a separator does nothing"); + assert!(track_matte_pick(&[bottom.id.0], &candidates, 1, "-").is_none(), "a separator does nothing"); } /// #957: vector footage gets the Continuously Rasterize switch; raster footage does not. diff --git a/crates/ui-egui/tests/ui_batch_links.rs b/crates/ui-egui/tests/ui_batch_links.rs new file mode 100644 index 000000000..3836c8143 --- /dev/null +++ b/crates/ui-egui/tests/ui_batch_links.rs @@ -0,0 +1,154 @@ +//! Parent and Track Matte for several layers at once (#1260): on a selected layer's row, the +//! Timeline's Parent and Track Matte menus and pick whips set every selected layer; on an +//! unselected row, that layer alone. One undo step each; each layer keeps its own matte mode. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, MatteKind}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable as _; +use serde_json::json; + +/// Solids A, B, C, P (moved and scaled, a parent) and the mattes X and Y; stacked Y, X, P, C, B, A +/// from the top. The Track Matte column is shown. +fn harness() -> (Harness<'static, EffectcraftApp>, [u64; 6]) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Links", "width": 320, "height": 180, "duration": 4})).unwrap(); + let ids = ["A", "B", "C", "P", "X", "Y"] + .map(|n| s.execute("layer.newSolid", json!({"name": n, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap()); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/position", "value": [60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": ids[3], "path": "transform/scale", "value": [50, 50]})).unwrap(); + s.history.undo.clear(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.show_modes = true; + h.state_mut().ui.timeline.columns_w = 900.0; + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn press(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.step(); + press(h, at, true); + h.step(); + press(h, at, false); + h.run_steps(3); +} + +/// Open the menu at automation id `dropdown` and choose `item` in it. +fn choose(h: &mut Harness<'_, EffectcraftApp>, dropdown: &str, item: &str) { + let menu = rect(h, dropdown); + click(h, menu.center()); + // (The menu may open above it to fit the window: the entry nearest to it.) + let off = |r: &Rect| (r.center().y - menu.center().y).abs(); + let at = h.query_all_by_label(item).map(|n| n.rect()).min_by(|a, b| off(a).total_cmp(&off(b))); + let at = at.unwrap_or_else(|| panic!("no {item} in the {dropdown} menu")).center(); + click(h, at); +} + +/// Drag the pick whip `whip` onto layer `onto`'s row. +fn whip(h: &mut Harness<'_, EffectcraftApp>, whip: &str, onto: u64) { + let (from, to) = (rect(h, whip).center(), rect(h, &format!("timeline.layer.{onto}.row")).center()); + h.event(Event::PointerMoved(from)); + h.step(); + press(h, from, true); + h.step(); + for i in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } + press(h, to, false); + h.run_steps(3); +} + +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +fn parent(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().parent.map(|p| p.0) +} + +fn position(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + +fn matte(h: &Harness<'_, EffectcraftApp>, id: u64) -> Option<(u64, MatteKind)> { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().track_matte.map(|m| (m.layer.0, m.kind)) +} + +/// The project and its undo steps as they are now, to compare with after an undo or a refusal. +fn mark(h: &Harness<'_, EffectcraftApp>) -> (std::sync::Arc, usize) { + (h.state().session.project.clone(), h.state().session.history.undo.len()) +} + +fn unchanged(h: &Harness<'_, EffectcraftApp>, m: &(std::sync::Arc, usize)) -> bool { + h.state().session.project.same_content(&m.0) && h.state().session.history.undo.len() == m.1 +} + +#[test] +fn parent_menu_and_pick_whip_parent_the_selected_layers() { + let (mut h, [a, b, c, p, ..]) = harness(); + let start = position(&h, a); + select(&mut h, &[a, b]); + let before = mark(&h); + // The Parent menu on A's (selected) row parents A and B, kept where they are. + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [Some(p), Some(p), None]); + assert!(position(&h, a) != start && position(&h, a) == position(&h, b), "both re-expressed in P's space"); + assert_eq!(h.state().session.history.undo.len(), before.1 + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both back"); + // The pick whip of an unselected row parents that layer alone. + whip(&mut h, &format!("timeline.layer.{c}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, c)], [None, None, Some(p)]); + // A selected row's whip parents the selection, less the parent itself if selected. + select(&mut h, &[a, b, p]); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), p); + assert_eq!([parent(&h, a), parent(&h, b), parent(&h, p)], [Some(p), Some(p), None]); + // A cycle (P under A, while A is under P) is refused as a whole. + select(&mut h, &[p, c]); + let before = mark(&h); + choose(&mut h, &format!("timeline.layer.{p}.parent"), "6. A"); + assert!(unchanged(&h, &before), "the refused parenting changed nothing"); + assert_eq!([parent(&h, p), parent(&h, c)], [None, Some(p)]); +} + +#[test] +fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() { + let (mut h, [a, b, c, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": a, "matte": x, "kind": "luma"})).unwrap(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layer": b, "matte": x, "kind": "alpha"})).unwrap(); + select(&mut h, &[a, b]); + let before = mark(&h); + // A new matte layer for both; each keeps its mode; C (not selected) keeps none. + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Luma)), Some((y, MatteKind::Alpha)), None]); + assert!(!h.state().session.active_comp().unwrap().layer(LayerId(y)).unwrap().switches.video, "Y is hidden as a matte"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(unchanged(&h, &before), "one undo puts both mattes and Y's visibility back"); + // A mode for both, then No Track Matte for both. + choose(&mut h, &format!("timeline.layer.{b}.trackMatte"), "Luma Inverted Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [Some((x, MatteKind::LumaInverted)), Some((x, MatteKind::LumaInverted))]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "No Track Matte"); + assert_eq!([matte(&h, a), matte(&h, b)], [None, None]); + // The track matte pick whip: a selected row's sets the selection's, an unselected row's + // its own. + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((y, MatteKind::Alpha)), None]); + whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x); + assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]); +} diff --git a/docs/agents.md b/docs/agents.md index 1bec71d51..29f2ac80a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -610,6 +610,13 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). Dragged onto another property's name or value, in the Timeline or Effect Controls (`effectControls.prop..value[.]`), it runs `prop.pickWhip`: the reference replaces the property's expression, or is added as one, and the expression is enabled. +* Parent and Track Matte for several layers: on a selected layer's row, the Timeline's Parent + menu and pick whip (`timeline.layer..parent`, `timeline.layer..pickWhip`) and its Track + Matte menu and pick whip (`timeline.layer..trackMatte`, + `timeline.layer..trackMattePickWhip`) set every selected layer's (on an unselected row, + that layer's), in one undo step. Directly: `layer.setParent {layers, parent}` and + `layer.setTrackMatte {layers, matte, kind}`, where `kind: "preserve"` keeps each layer's + own matte mode. * A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset (`prop.reset`: no expression, and the property's default value, an effect parameter's or a Transform property's; an animated property keeps its keyframes and gets the default as a From 6f57b9eee3632adeecf106d8ccf80ff5aad0bdba Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 12:19:35 -0400 Subject: [PATCH 0030/1386] Parent and Track Matte: locked layers keep theirs (#1260) With several layers set at once from one selected row, a selected layer locked after it was selected (its Lock switch doesn't deselect it) had its parent, its compensated transform or its matte changed too. layer.setParent, layer.setTrackMatte and layer.trackMatte now leave locked layers as they are, the rule the other layer edits follow (unlocked): among several, the others change; alone, it is an error that changes nothing. Done in the engine, a layer locked while a pick whip is being dragged is covered too. Test: locked_layers_keep_their_parent_and_matte. --- crates/engine/src/commands/layer.rs | 34 ++++++++++++++++++++++-- crates/engine/src/commands/layer_menu.rs | 1 + 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 4ccfbe3e6..bbaa87c86 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -551,8 +551,8 @@ fn blend(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer) use `matte` as -/// their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps +/// `layer.setTrackMatte`: `layers` (or `layer`, else the first selected layer; locked ones left +/// as they are) use `matte` as their track matte (none when null), in one undo step. `kind` sets the mode; `preserve` keeps /// each layer's own (Alpha for a layer without a matte), so changing the matte of several layers /// at once keeps their modes. fn track_matte(s: &mut Session, p: &Value) -> Result { @@ -560,6 +560,8 @@ fn track_matte(s: &mut Session, p: &Value) -> Result { Some(_) => layers_p(s, p)?, None => layer_p(s, p, "layer.setTrackMatte").map(|(c, l)| (c, vec![l]))?, }; + // Locked layers keep their matte (several at once: the others get it). + let ids = unlocked(s, cid, ids, "layer.setTrackMatte")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let matte = match p.get("matte") { None | Some(Value::Null) => None, @@ -906,6 +908,8 @@ fn check_parent_chain(comp: &Comp, ids: &[LayerId], parent: Option, max fn set_parent(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + // Locked layers keep their parent (several at once: the others are parented). + let ids = unlocked(s, cid, ids, "layer.setParent")?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let parent = match p.get("parent") { None | Some(Value::Null) => None, @@ -1922,4 +1926,30 @@ mod track_matte_batch_tests { s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": null})).unwrap(); assert_eq!((matte_of(&s, a), matte_of(&s, b)), (None, None), "both lose it"); } + + /// Locked layers keep their parent and matte when they are among several (the others change), + /// and alone they are an error that changes nothing. + #[test] + fn locked_layers_keep_their_parent_and_matte() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Locks","width":16,"height":16,"duration":1})).unwrap(); + let [a, b, p, m] = ["A", "B", "P", "M"].map(|n| s.execute("layer.newSolid", json!({"name": n})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let layer = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + s.execute("layer.setParent", json!({"layers": [a, b], "parent": p})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": m, "kind": "preserve"})).unwrap(); + s.execute("layer.trackMatte", json!({"layers": [a, b], "op": "luma"})).unwrap(); + assert_eq!((layer(&s, a).parent, matte_of(&s, a)), (Some(LayerId(p)), Some((m, MatteKind::Luma)))); + assert_eq!((layer(&s, b).parent, matte_of(&s, b)), (None, None), "the locked layer is left as it was"); + for (cmd, p) in [ + ("layer.setParent", json!({"layers": [b], "parent": p})), + ("layer.setTrackMatte", json!({"layers": [b], "matte": m})), + ("layer.trackMatte", json!({"layers": [b], "op": "alpha"})), + ] { + let (project, revision) = (s.project.clone(), s.revision); + let err = s.execute(cmd, p).unwrap_err(); + assert!(err.to_string().contains("is locked"), "{cmd}: {err}"); + assert!(Arc::ptr_eq(&project, &s.project) && s.revision == revision, "{cmd} changed nothing"); + } + } } diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 7737b8afb..898e5d4f9 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -609,6 +609,7 @@ fn delete_all_markers(s: &mut Session, p: &Value) -> Result { fn track_matte_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; + let ids = super::unlocked(s, cid, ids, "layer.trackMatte")?; let op = str_p(p, "op").ok_or_else(|| bad("layer.trackMatte", "missing `op`"))?.to_string(); let kind = match op.as_str() { "alpha" => Some(MatteKind::Alpha), From 987faa0b20c418b7ca999760aed45d00fbfc321f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 12:19:36 -0400 Subject: [PATCH 0031/1386] Timeline: selected nulls get no track matte (#1260) The Track Matte menu and pick whip applied to every selected layer, nulls, cameras and lights included, though their rows have no Track Matte. A mode chosen for selected solids then went to a selected null too: at the top of the comp it had no layer above to take as its matte and the whole change was refused; at the bottom it took the layer above as its matte and hid it. Track Matte edits now go to the selected layers that can have a matte (matte_layers); parenting still includes nulls, cameras and lights. Tests (ui_batch_links): a selected null on top or at the bottom gets no matte and changes nothing else; a layer locked with its Lock switch stays selected and keeps its parent, transform and matte through the other row's menus and pick whips. Both fail before. --- crates/ui-egui/src/panels/timeline.rs | 14 ++++++- crates/ui-egui/tests/ui_batch_links.rs | 51 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f2d91fa3d..49da29181 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -178,6 +178,12 @@ fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } } +/// The layers a Track Matte edit on `layer`'s row applies to: [`edited_layers`] that can have a +/// matte. Selected nulls, cameras and lights (no Track Matte on their rows) are left out. +fn matte_layers(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { + edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() +} + /// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for /// the layer's own), drawn and dropped at the end of [`show`]. #[allow(clippy::too_many_arguments)] @@ -196,7 +202,11 @@ fn pick_whip( app.auto.add(auto, rect, label); if resp.drag_started() { let state: PickWhip = (kind, layer, prop, rect.center()); - let layers = edited_layers(LayerId(layer), &app.session.state.selected_layers); + let selected = &app.session.state.selected_layers; + let layers = match (kind, app.session.active_comp()) { + (WHIP_MATTE, Some(comp)) => matte_layers(comp, LayerId(layer), selected), + _ => edited_layers(LayerId(layer), selected), + }; ui.ctx().data_mut(|d| { d.insert_temp(pick_whip_id(), state); d.insert_temp(whip_layers_id(), layers); @@ -2208,7 +2218,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label())); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) { let ids: Vec = candidates.iter().map(|l| l.id).collect(); - if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&edited_layers(layer.id, &selected), &ids, i, o)) { + if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(&matte_layers(&comp, layer.id, &selected), &ids, i, o)) { actions.push(a); } } diff --git a/crates/ui-egui/tests/ui_batch_links.rs b/crates/ui-egui/tests/ui_batch_links.rs index 3836c8143..ce5845faa 100644 --- a/crates/ui-egui/tests/ui_batch_links.rs +++ b/crates/ui-egui/tests/ui_batch_links.rs @@ -152,3 +152,54 @@ fn track_matte_menu_and_pick_whip_set_the_selected_layers_mattes() { whip(&mut h, &format!("timeline.layer.{c}.trackMattePickWhip"), x); assert_eq!([matte(&h, a), matte(&h, c)], [Some((y, MatteKind::Alpha)), Some((x, MatteKind::Alpha))]); } + +fn video(h: &Harness<'_, EffectcraftApp>, id: u64) -> bool { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.video +} + +/// A selected layer locked with its Lock switch stays selected, and keeps its parent, its +/// transform and its matte while another selected row's menus and pick whips change the rest. +#[test] +fn locked_selected_layers_keep_their_parent_and_matte() { + let (mut h, [a, b, c, _, x, _]) = harness(); + select(&mut h, &[a, b]); + let at = rect(&h, &format!("timeline.layer.{b}.lock")).center(); + click(&mut h, at); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(b)).unwrap().switches.locked && selected == vec![a, b], "{selected:?}"); + let b_position = position(&h, b); + choose(&mut h, &format!("timeline.layer.{a}.parent"), "3. P"); + whip(&mut h, &format!("timeline.layer.{a}.pickWhip"), c); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "1. Y"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), x); + assert_eq!((parent(&h, a), matte(&h, a)), (Some(c), Some((x, MatteKind::Alpha))), "A changed each time"); + assert_eq!((parent(&h, b), matte(&h, b), position(&h, b)), (None, None, b_position), "locked B didn't"); +} + +/// Selected nulls (no Track Matte on their rows) get no matte from another row's menu or pick +/// whip: at the top of the comp one used to refuse the whole mode change (nothing above it to +/// take as a matte), at the bottom it took the layer above as its matte and hid it. +#[test] +fn selected_nulls_get_no_matte() { + let (mut h, [a, b, _, _, x, y]) = harness(); + h.state_mut().session.execute("layer.setTrackMatte", json!({"layers": [a, b], "matte": x, "kind": "alpha"})).unwrap(); + let n = h.state_mut().session.execute("layer.newNull", json!({"name": "N"})).unwrap()["layer"].as_u64().unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Luma Matte"); + assert_eq!([matte(&h, a), matte(&h, b), matte(&h, n)], [Some((x, MatteKind::Luma)), Some((x, MatteKind::Luma)), None], "a null on top"); + h.state_mut().session.execute("layer.arrange", json!({"layers": [n], "to": "back"})).unwrap(); + select(&mut h, &[a, b, n]); + choose(&mut h, &format!("timeline.layer.{a}.trackMatte"), "Alpha Inverted Matte"); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((x, MatteKind::AlphaInverted)), Some((x, MatteKind::AlphaInverted)), None], + "a null at the bottom" + ); + assert!(video(&h, a), "A wasn't hidden as the null's matte"); + whip(&mut h, &format!("timeline.layer.{a}.trackMattePickWhip"), y); + assert_eq!( + [matte(&h, a), matte(&h, b), matte(&h, n)], + [Some((y, MatteKind::AlphaInverted)), Some((y, MatteKind::AlphaInverted)), None], + "the pick whip too" + ); +} From e923a645f7e65931dbe9ad2794ecb7b3bd04b0c3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 12:27:50 -0400 Subject: [PATCH 0032/1386] UI: colour expression-driven values red in the Timeline, Effect Controls and Properties (#1306) A property driven by an expression showed its value in the ordinary blue; After Effects shows it red (refs #1072). Tokens::for_value(expression) gives the value fields the expression colour (expr_text, dark and light) for both hot_text and accent_hover, so a hovered or dragged value stays red. The Timeline's value_editor, Effect Controls' parameter rows and the Properties panel's transform rows pass these value tokens to their number and angle fields (and the Timeline's vector separator and plain enum number), from Property::has_expression() every frame: a disabled, removed or empty expression is blue again. Names, stopwatches, keyframe buttons, link icons and dropdowns keep the ordinary tokens. Red means an expression drives the value, not that it evaluated; errors keep their own reporting. Tests (ui_expression_values, reading the painted text colour): a vector (Position, with its separator), an angle (Rotation, both parts) and a scalar (Opacity), on the layer and on the Transform effect, are red in all three panels, in dark and light themes, and Scale (no expression) stays blue; disabled, removed and whitespace-only expressions are blue and enabling again is red; a hovered expression value stays red while a hovered ordinary one takes the hover accent. All three fail without this change. ui_effect_controls and ui_inline_edit still pass. --- crates/ui-egui/src/panels/effect_controls.rs | 8 +- crates/ui-egui/src/panels/properties.rs | 8 +- crates/ui-egui/src/panels/timeline.rs | 12 +- crates/ui-egui/src/theme.rs | 10 + crates/ui-egui/tests/ui_expression_values.rs | 187 +++++++++++++++++++ 5 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/ui_expression_values.rs diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..a236e39e8 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -219,6 +219,8 @@ fn prop_row( actions: &mut Actions, ) -> Vec { let t = app.tokens; + // The values themselves: red while an expression drives the parameter (#1306). + let vt = t.for_value(prop.has_expression()); let mut dims = vec![]; let cy = r.center().y; let uid = prop.uid; @@ -312,7 +314,7 @@ fn prop_row( } } Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &t); + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &vt); app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -342,7 +344,7 @@ fn prop_row( (min, max), dec.max(if dec == 0 { 0 } else { 1 }), suffix, - &t, + &vt, ); app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); if let Some(nv) = nv { @@ -370,7 +372,7 @@ fn prop_row( x += 22.0; } for d in 0..n.min(c.len()) { - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &vt); app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); dims.push(vr); if let Some(nv) = nv { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..ed5a80bd8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -193,6 +193,8 @@ fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { #[allow(clippy::too_many_arguments)] fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = t.for_value(pr.has_expression()); let cy = r.center().y; let uid = pr.uid; let lid = layer.id.0; @@ -258,7 +260,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match &value { Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => { let (_, deg) = super::fx_widgets::split_angle(*v); - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &vt); app.auto.add(&format!("{base}.value"), dr, &pr.name); app.auto.add(&format!("{base}.revolutions"), rr, &pr.name); if let Some(nv) = nv { @@ -267,7 +269,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } Value::Scalar(v) => { let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) }; - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t); + let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &vt); app.auto.add(&format!("{base}.value"), vr, &pr.name); if let Some(nv) = nv { set(actions, json!(nv)); @@ -293,7 +295,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } for d in 0..n.min(c.len()) { let (vr, nv, _) = - widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t); + widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &vt); app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { let mut nc = c.clone(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..88bd81eef 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3987,6 +3987,8 @@ fn value_editor( actions: &mut Vec<(String, serde_json::Value)>, ) -> Vec { let t = app.tokens; + // The values themselves: red while an expression drives the property (#1306). + let vt = t.for_value(prop.has_expression()); let uid = prop.uid; let mut dims = vec![]; let merge = format!("scrub-{uid}"); @@ -4013,7 +4015,7 @@ fn value_editor( _ => (-1e9, 1e9, 1, 1.0), }; if matches!(prop.ui, ParamUi::Angle) { - let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &t); + let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &vt); app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name); app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name); if let Some(nv) = nv { @@ -4021,7 +4023,7 @@ fn value_editor( } } else { let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" }; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &vt); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { set(actions, json!(nv)); @@ -4050,7 +4052,7 @@ fn value_editor( } for d in 0..n.min(c.len()) { let suffix = if pct && d + 1 == n { "%" } else { "" }; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &vt); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); dims.push(r); if let Some(nv) = nv { @@ -4067,7 +4069,7 @@ fn value_editor( } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), vt.hot_text); x += 6.0; } } @@ -4123,7 +4125,7 @@ fn value_editor( set(actions, json!(ni)); } } else { - p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text); + p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), vt.hot_text); } } Value::Path(_) => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..623c5ca5c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -125,6 +125,16 @@ impl Tokens { pub fn grad_top(&self, c: Color32) -> Color32 { if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } } + + /// The tokens a property's value fields use (#1306): with an expression driving the property + /// (`expression`, [`Property::has_expression`]) its values show in the expression colour, red + /// as in After Effects, hovered and dragged too; otherwise these tokens. For the values only: + /// the name, stopwatch and swatches keep the ordinary tokens. + /// + /// [`Property::has_expression`]: effectcraft_engine::project::Property::has_expression + pub fn for_value(self, expression: bool) -> Tokens { + if expression { Tokens { hot_text: self.expr_text, accent_hover: self.expr_text, ..self } } else { self } + } } impl Tokens { diff --git a/crates/ui-egui/tests/ui_expression_values.rs b/crates/ui-egui/tests/ui_expression_values.rs new file mode 100644 index 000000000..9b226429b --- /dev/null +++ b/crates/ui-egui/tests/ui_expression_values.rs @@ -0,0 +1,187 @@ +//! #1306 (from #1072): a property driven by an expression shows its value in red, the expression +//! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars, +//! every part of a vector (with its separator) and both parts of an angle, hovered too, in dark and +//! light themes. A disabled, removed or empty expression shows the ordinary colour again. The +//! tests read the colour the value text is painted with. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::theme::ThemeKind; +use egui::epaint::Shape; +use egui::{Color32, Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +const PANELS: [&str; 3] = ["timeline.prop", "effectControls.prop", "properties.prop"]; + +/// A comp with one solid: (layer, transform Position / Rotation / Opacity / Scale uids, the +/// Transform effect's Position / Rotation / Opacity uids). +struct Ids { + layer: u64, + layer_props: [u64; 3], + scale: u64, + effect_props: [u64; 3], +} + +fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "E", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [l], "effect": "Transform"})).unwrap()["effects"][0].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let g: &PropGroup = layer.props.find_group(fx).unwrap(); + let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid; + let ids = Ids { + layer: l, + layer_props: [uid("transform/position"), uid("transform/rotation"), uid("transform/opacity")], + scale: uid("transform/scale"), + effect_props: [fuid("position"), fuid("rotation"), fuid("opacity")], + }; + let transform = layer.props.sub("transform").unwrap().uid; + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.ui.timeline.open_layers.insert(l); + app.ui.timeline.open_groups.insert(transform); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, ids) +} + +fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: serde_json::Value) { + let mut p = p; + p["layer"] = json!(ids.layer); + p["prop"] = json!(prop); + h.state_mut().session.execute("prop.setExpression", p).unwrap(); + h.run_steps(3); +} + +/// The value's area in `panel`: its value, revolutions and vector components together (so a +/// vector's separator and an angle's sign are inside). +fn value_rect(h: &Harness<'_, EffectcraftApp>, panel: &str, uid: u64) -> Option { + let ids = [ + format!("{panel}.{uid}.value"), + format!("{panel}.{uid}.revolutions"), + format!("{panel}.{uid}.value.0"), + format!("{panel}.{uid}.value.1"), + format!("{panel}.{uid}.value.2"), + ]; + ids.iter() + .filter_map(|id| h.state().auto.find(id)) + .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))) + .reduce(|a, b| a.union(b)) +} + +/// The colours of the text painted inside `area` in the last frame. +fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec { + fn walk(s: &Shape, area: Rect, out: &mut Vec) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)), + Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => { + out.push(t.override_text_color.unwrap_or(t.fallback_color)); + } + _ => {} + } + } + let mut out = vec![]; + for c in &h.output().shapes { + walk(&c.shape, area, &mut out); + } + out +} + +/// The colours `uid`'s value is painted with in every panel (each panel must show it). +fn value_colors(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<(&'static str, Vec)> { + PANELS + .iter() + .filter_map(|panel| { + let area = value_rect(h, panel, uid)?; + Some((*panel, text_colors(h, area))) + }) + .collect() +} + +fn assert_all(h: &Harness<'_, EffectcraftApp>, uids: &[u64], want: Color32, what: &str) { + for &uid in uids { + let shown = value_colors(h, uid); + assert!(!shown.is_empty(), "{what}: @{uid} is shown in a panel"); + for (panel, colors) in shown { + assert!(!colors.is_empty(), "{what}: @{uid} in {panel} paints its value"); + assert!(colors.iter().all(|c| *c == want), "{what}: @{uid} in {panel}: {colors:?}, want {want:?}"); + } + } +} + +/// Expression-driven values are red in every panel and both themes; other values stay blue. +#[test] +fn expression_driven_values_are_red_in_every_panel() { + let (mut h, ids) = setup(); + for p in ids.layer_props.iter().chain(&ids.effect_props) { + set_expression(&mut h, &ids, *p, json!({"expression": "value"})); + } + // Every value is shown somewhere: the layer's in the Timeline and Properties, the effect's in + // Effect Controls. + for p in ids.layer_props { + assert!(value_rect(&h, "timeline.prop", p).is_some() && value_rect(&h, "properties.prop", p).is_some(), "@{p} in the Timeline and Properties"); + } + for p in ids.effect_props { + assert!(value_rect(&h, "effectControls.prop", p).is_some(), "@{p} in Effect Controls"); + } + for theme in [ThemeKind::Dark, ThemeKind::Light] { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + h.run_steps(3); + let t = h.state().tokens; + assert_ne!(t.expr_text, t.hot_text); + let all: Vec = ids.layer_props.iter().chain(&ids.effect_props).copied().collect(); + assert_all(&h, &all, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); + } +} + +/// Disabled, removed or empty: the ordinary colour; enabled again: red. +#[test] +fn disabled_removed_and_empty_expressions_show_the_ordinary_colour() { + let (mut h, ids) = setup(); + let [position, rotation, opacity] = ids.layer_props; + for p in ids.layer_props { + set_expression(&mut h, &ids, p, json!({"expression": "value"})); + } + let t = h.state().tokens; + assert_all(&h, &ids.layer_props, t.expr_text, "enabled"); + set_expression(&mut h, &ids, position, json!({"enabled": false})); + set_expression(&mut h, &ids, rotation, json!({"expression": ""})); + set_expression(&mut h, &ids, opacity, json!({"expression": " "})); + assert_all(&h, &ids.layer_props, t.hot_text, "disabled, removed, empty"); + set_expression(&mut h, &ids, position, json!({"enabled": true})); + assert_all(&h, &[position], t.expr_text, "enabled again"); +} + +fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); +} + +/// Hovering a value (which turns an ordinary value's colour to the hover accent) keeps an +/// expression-driven one red. +#[test] +fn a_hovered_expression_value_stays_red() { + let (mut h, ids) = setup(); + let [_, _, opacity] = ids.layer_props; + set_expression(&mut h, &ids, opacity, json!({"expression": "value"})); + let t = h.state().tokens; + for panel in ["timeline.prop", "properties.prop"] { + let area = value_rect(&h, panel, opacity).expect("the value"); + hover(&mut h, area.center()); + let colors = text_colors(&h, area); + assert!(!colors.is_empty() && colors.iter().all(|c| *c == t.expr_text), "{panel} hovered: {colors:?}"); + } + // An ordinary value does take the hover accent there. + let area = value_rect(&h, "timeline.prop", ids.scale).expect("Scale"); + hover(&mut h, area.left_center() + vec2(6.0, 0.0)); + let colors = text_colors(&h, area); + assert!(colors.contains(&t.accent_hover), "an ordinary hovered value: {colors:?}"); +} From 637f2f2ba86a6504142248a4793a8d1e9fec278b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 12:27:57 -0400 Subject: [PATCH 0033/1386] Docs: gaps.md records the #1306 expression value colour tests --- docs/gaps.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..32cdd8785 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,6 +150,11 @@ The most important missing piece: it turns every other estimate here into a meas a Radial Stroke swatch on a selected shape layer and get the Gradient Editor on its Gradient Fill / Gradient Stroke Colors (OK saves); without such a gradient the swatch still opens the options, and the words, Solid swatches and Alt-click are unchanged. +- Regression evidence for [#1306](https://github.com/storytold/effectcraft/issues/1306) (from + #1072): the `ui_expression_values` tests, checked headlessly on Windows, read the colour the + values are painted in. Expression-driven vectors, angles and scalars are red in the Timeline, + Effect Controls and Properties panel, in dark and light themes, hovered too; disabled, removed + and empty expressions show the ordinary colour. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template From 764e9c5cde243b0354d0a1cf28637a70c188d935 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:02:37 -0400 Subject: [PATCH 0034/1386] UI: expression value colour tests require every part, hover in every panel and a drag (#1306) Each checked property now names the panels that must show it: the layer's Position, Rotation and Opacity in the Timeline and Properties panel, the Transform effect's in Effect Controls. Every component rectangle is required, and the painted text must include both numbers of a vector (and the Timeline's comma), the revolutions, sign and degrees of an angle, and the number of a scalar. These presence checks run in both themes and after every expression state change. Hover now covers all three panels in dark and light themes, and a new test scrubs Opacity in the Timeline and Effect Controls, confirming a drag is active before checking the colour. All four tests fail on main. --- crates/ui-egui/tests/ui_expression_values.rs | 270 ++++++++++++------- 1 file changed, 178 insertions(+), 92 deletions(-) diff --git a/crates/ui-egui/tests/ui_expression_values.rs b/crates/ui-egui/tests/ui_expression_values.rs index 9b226429b..6c07b774e 100644 --- a/crates/ui-egui/tests/ui_expression_values.rs +++ b/crates/ui-egui/tests/ui_expression_values.rs @@ -1,8 +1,10 @@ //! #1306 (from #1072): a property driven by an expression shows its value in red, the expression //! colour, in the Timeline, Effect Controls and the Properties panel, as in After Effects: scalars, -//! every part of a vector (with its separator) and both parts of an angle, hovered too, in dark and -//! light themes. A disabled, removed or empty expression shows the ordinary colour again. The -//! tests read the colour the value text is painted with. +//! every part of a vector (with its separator) and both parts of an angle (with its sign), hovered +//! and dragged too, in dark and light themes. A disabled, removed or empty expression shows the +//! ordinary colour again. The tests read the colour the value text is painted with (the frame's +//! text shapes: the numbers are painted with an override colour, the separator and sign as plain +//! text), and require every expected part to be there. use effectcraft_engine::Session; use effectcraft_engine::project::{LayerId, PropGroup}; @@ -10,19 +12,39 @@ use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::theme::ThemeKind; use egui::epaint::Shape; -use egui::{Color32, Event, Pos2, Rect, pos2, vec2}; +use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; use serde_json::json; -const PANELS: [&str; 3] = ["timeline.prop", "effectControls.prop", "properties.prop"]; +const TIMELINE: &str = "timeline.prop"; +const EFFECT_CONTROLS: &str = "effectControls.prop"; +const PROPERTIES: &str = "properties.prop"; -/// A comp with one solid: (layer, transform Position / Rotation / Opacity / Scale uids, the -/// Transform effect's Position / Rotation / Opacity uids). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Two components (X, Y). + Vector, + /// Revolutions, sign and degrees. + Angle, + Scalar, +} + +/// A property whose value the tests check, and the panels that must show it. +#[derive(Clone, Copy, Debug)] +struct Checked { + uid: u64, + kind: Kind, + panels: &'static [&'static str], +} + +/// A comp with one solid and the Transform effect on it. struct Ids { layer: u64, - layer_props: [u64; 3], - scale: u64, - effect_props: [u64; 3], + /// Transform Position, Rotation and Opacity (Timeline and Properties panel), and the Transform + /// effect's Position, Rotation and Opacity (Effect Controls). + driven: [Checked; 6], + /// Transform Scale: no expression. + scale: Checked, } fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { @@ -34,11 +56,20 @@ fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { let uid = |path: &str| layer.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; let g: &PropGroup = layer.props.find_group(fx).unwrap(); let fuid = |m: &str| g.get(m).unwrap_or_else(|| panic!("no effect {m}")).uid; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + const EFFECT: &[&str] = &[EFFECT_CONTROLS]; + let c = |uid, kind, panels| Checked { uid, kind, panels }; let ids = Ids { layer: l, - layer_props: [uid("transform/position"), uid("transform/rotation"), uid("transform/opacity")], - scale: uid("transform/scale"), - effect_props: [fuid("position"), fuid("rotation"), fuid("opacity")], + driven: [ + c(uid("transform/position"), Kind::Vector, LAYER), + c(uid("transform/rotation"), Kind::Angle, LAYER), + c(uid("transform/opacity"), Kind::Scalar, LAYER), + c(fuid("position"), Kind::Vector, EFFECT), + c(fuid("rotation"), Kind::Angle, EFFECT), + c(fuid("opacity"), Kind::Scalar, EFFECT), + ], + scale: c(uid("transform/scale"), Kind::Vector, LAYER), }; let transform = layer.props.sub("transform").unwrap().uid; s.state.selected_layers = vec![LayerId(l)]; @@ -51,37 +82,42 @@ fn setup() -> (Harness<'static, EffectcraftApp>, Ids) { (h, ids) } -fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: serde_json::Value) { - let mut p = p; +fn set_expression(h: &mut Harness<'_, EffectcraftApp>, ids: &Ids, prop: u64, p: &serde_json::Value) { + let mut p = p.clone(); p["layer"] = json!(ids.layer); p["prop"] = json!(prop); h.state_mut().session.execute("prop.setExpression", p).unwrap(); +} + +fn set_theme(h: &mut Harness<'_, EffectcraftApp>, theme: ThemeKind) { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); h.run_steps(3); } -/// The value's area in `panel`: its value, revolutions and vector components together (so a -/// vector's separator and an angle's sign are inside). -fn value_rect(h: &Harness<'_, EffectcraftApp>, panel: &str, uid: u64) -> Option { - let ids = [ - format!("{panel}.{uid}.value"), - format!("{panel}.{uid}.revolutions"), - format!("{panel}.{uid}.value.0"), - format!("{panel}.{uid}.value.1"), - format!("{panel}.{uid}.value.2"), - ]; - ids.iter() - .filter_map(|id| h.state().auto.find(id)) - .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))) - .reduce(|a, b| a.union(b)) +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}: the part isn't shown")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) } -/// The colours of the text painted inside `area` in the last frame. -fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec { - fn walk(s: &Shape, area: Rect, out: &mut Vec) { +/// The area of `c`'s value in `panel`, every expected part required: both components of a vector, +/// the revolutions and degrees of an angle (the separator and sign lie between them). +fn value_area(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked) -> Rect { + let uid = c.uid; + match c.kind { + Kind::Vector => rect_of(h, &format!("{panel}.{uid}.value.0")).union(rect_of(h, &format!("{panel}.{uid}.value.1"))), + Kind::Angle => rect_of(h, &format!("{panel}.{uid}.revolutions")).union(rect_of(h, &format!("{panel}.{uid}.value"))), + Kind::Scalar => rect_of(h, &format!("{panel}.{uid}.value")), + } +} + +/// The text painted inside `area` in the last frame, with its colour. +fn texts(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec<(String, Color32)> { + fn walk(s: &Shape, area: Rect, out: &mut Vec<(String, Color32)>) { match s { Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, out)), Shape::Text(t) if area.expand(1.0).contains_rect(t.visual_bounding_rect()) && !t.galley.text().trim().is_empty() => { - out.push(t.override_text_color.unwrap_or(t.fallback_color)); + out.push((t.galley.text().trim().to_string(), t.override_text_color.unwrap_or(t.fallback_color))); } _ => {} } @@ -93,71 +129,80 @@ fn text_colors(h: &Harness<'_, EffectcraftApp>, area: Rect) -> Vec { out } -/// The colours `uid`'s value is painted with in every panel (each panel must show it). -fn value_colors(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<(&'static str, Vec)> { - PANELS - .iter() - .filter_map(|panel| { - let area = value_rect(h, panel, uid)?; - Some((*panel, text_colors(h, area))) - }) - .collect() +/// `c`'s value in `panel` shows every part, all in `want`: two numbers and (in the Timeline) the +/// `,` of a vector, the revolutions, sign and degrees of an angle, the number of a scalar. +fn assert_value(h: &Harness<'_, EffectcraftApp>, panel: &str, c: Checked, want: Color32, what: &str) { + let shown = texts(h, value_area(h, panel, c)); + let numbers = shown.iter().filter(|(s, _)| s.chars().any(|ch| ch.is_ascii_digit())).count(); + let has = |text: &str| shown.iter().any(|(s, _)| s == text); + let ctx = format!("{what}: {:?} @{} in {panel}: {shown:?}", c.kind, c.uid); + match c.kind { + Kind::Vector => { + assert!(numbers >= 2, "{ctx}: both components"); + if panel == TIMELINE { + assert!(has(","), "{ctx}: the separator"); + } + } + Kind::Angle => { + assert!(numbers >= 2, "{ctx}: revolutions and degrees"); + assert!(has("+") || has("-"), "{ctx}: the sign"); + } + Kind::Scalar => assert!(numbers >= 1, "{ctx}: the number"), + } + assert!(shown.iter().all(|(_, col)| *col == want), "{ctx}: want {want:?}"); } -fn assert_all(h: &Harness<'_, EffectcraftApp>, uids: &[u64], want: Color32, what: &str) { - for &uid in uids { - let shown = value_colors(h, uid); - assert!(!shown.is_empty(), "{what}: @{uid} is shown in a panel"); - for (panel, colors) in shown { - assert!(!colors.is_empty(), "{what}: @{uid} in {panel} paints its value"); - assert!(colors.iter().all(|c| *c == want), "{what}: @{uid} in {panel}: {colors:?}, want {want:?}"); +/// Every checked property in every panel it belongs to. +fn assert_all(h: &Harness<'_, EffectcraftApp>, checked: &[Checked], want: Color32, what: &str) { + for c in checked { + for panel in c.panels { + assert_value(h, panel, *c, want, what); } } } -/// Expression-driven values are red in every panel and both themes; other values stay blue. +/// Expression-driven values are red in every panel, in both themes; Scale (none) stays blue. #[test] fn expression_driven_values_are_red_in_every_panel() { let (mut h, ids) = setup(); - for p in ids.layer_props.iter().chain(&ids.effect_props) { - set_expression(&mut h, &ids, *p, json!({"expression": "value"})); - } - // Every value is shown somewhere: the layer's in the Timeline and Properties, the effect's in - // Effect Controls. - for p in ids.layer_props { - assert!(value_rect(&h, "timeline.prop", p).is_some() && value_rect(&h, "properties.prop", p).is_some(), "@{p} in the Timeline and Properties"); - } - for p in ids.effect_props { - assert!(value_rect(&h, "effectControls.prop", p).is_some(), "@{p} in Effect Controls"); + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); } for theme in [ThemeKind::Dark, ThemeKind::Light] { - let ctx = h.ctx.clone(); - h.state_mut().set_theme(&ctx, theme); - h.run_steps(3); + set_theme(&mut h, theme); let t = h.state().tokens; assert_ne!(t.expr_text, t.hot_text); - let all: Vec = ids.layer_props.iter().chain(&ids.effect_props).copied().collect(); - assert_all(&h, &all, t.expr_text, &format!("{theme:?} expression")); + assert_all(&h, &ids.driven, t.expr_text, &format!("{theme:?} expression")); assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} no expression")); } } -/// Disabled, removed or empty: the ordinary colour; enabled again: red. +/// Disabled, removed or whitespace-only: the ordinary colour; enabled again: red. Every part of +/// every value is checked again after each change, in both themes. #[test] -fn disabled_removed_and_empty_expressions_show_the_ordinary_colour() { +fn expression_state_changes_switch_the_colour() { let (mut h, ids) = setup(); - let [position, rotation, opacity] = ids.layer_props; - for p in ids.layer_props { - set_expression(&mut h, &ids, p, json!({"expression": "value"})); + let states = [ + (json!({"expression": "value"}), true, "enabled"), + (json!({"enabled": false}), false, "disabled"), + (json!({"enabled": true}), true, "enabled again"), + (json!({"expression": ""}), false, "removed"), + (json!({"expression": "value"}), true, "set again"), + (json!({"expression": " "}), false, "whitespace only"), + ]; + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for (p, red, what) in &states { + for c in ids.driven { + set_expression(&mut h, &ids, c.uid, p); + } + h.run_steps(3); + let want = if *red { t.expr_text } else { t.hot_text }; + assert_all(&h, &ids.driven, want, &format!("{theme:?} {what}")); + assert_all(&h, &[ids.scale], t.hot_text, &format!("{theme:?} {what}: Scale")); + } } - let t = h.state().tokens; - assert_all(&h, &ids.layer_props, t.expr_text, "enabled"); - set_expression(&mut h, &ids, position, json!({"enabled": false})); - set_expression(&mut h, &ids, rotation, json!({"expression": ""})); - set_expression(&mut h, &ids, opacity, json!({"expression": " "})); - assert_all(&h, &ids.layer_props, t.hot_text, "disabled, removed, empty"); - set_expression(&mut h, &ids, position, json!({"enabled": true})); - assert_all(&h, &[position], t.expr_text, "enabled again"); } fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { @@ -165,23 +210,64 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { h.run_steps(2); } -/// Hovering a value (which turns an ordinary value's colour to the hover accent) keeps an -/// expression-driven one red. +/// Hovering a value (an ordinary one takes the hover accent) keeps an expression-driven one red, +/// in every panel and both themes. #[test] fn a_hovered_expression_value_stays_red() { let (mut h, ids) = setup(); - let [_, _, opacity] = ids.layer_props; - set_expression(&mut h, &ids, opacity, json!({"expression": "value"})); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + for c in &opacity { + for panel in c.panels { + let area = value_area(&h, panel, *c); + hover(&mut h, area.center()); + let at = h.ctx.input(|i| i.pointer.hover_pos()); + assert!(at.is_some_and(|p| area.contains(p)), "{theme:?} {panel}: the pointer is over the value"); + assert_value(&h, panel, *c, t.expr_text, &format!("{theme:?} hovered")); + } + } + // An ordinary value does take the hover accent. + let area = value_area(&h, TIMELINE, ids.scale); + let x = rect_of(&h, &format!("{TIMELINE}.{}.value.0", ids.scale.uid)).center(); + hover(&mut h, x); + let shown = texts(&h, area); + assert!(shown.iter().any(|(_, c)| *c == t.accent_hover), "{theme:?} an ordinary hovered value: {shown:?}"); + hover(&mut h, pos2(5.0, 5.0)); + } +} + +/// Dragging (scrubbing) an expression-driven value keeps it red while the drag is on, in the +/// Timeline and Effect Controls. +#[test] +fn a_dragged_expression_value_stays_red() { + let (mut h, ids) = setup(); + let opacity = ids.driven.iter().filter(|c| c.kind == Kind::Scalar).copied().collect::>(); + for c in &opacity { + set_expression(&mut h, &ids, c.uid, &json!({"expression": "value"})); + } + h.run_steps(3); let t = h.state().tokens; - for panel in ["timeline.prop", "properties.prop"] { - let area = value_rect(&h, panel, opacity).expect("the value"); - hover(&mut h, area.center()); - let colors = text_colors(&h, area); - assert!(!colors.is_empty() && colors.iter().all(|c| *c == t.expr_text), "{panel} hovered: {colors:?}"); + for (c, panel) in [(opacity[0], TIMELINE), (opacity[1], EFFECT_CONTROLS)] { + let at = value_area(&h, panel, c).center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(at + vec2(3.0 * k as f32, 0.0))); + h.step(); + } + assert!(h.ctx.dragged_id().is_some(), "{panel}: a drag is on"); + assert_value(&h, panel, c, t.expr_text, &format!("{panel} dragged")); + let end = at + vec2(12.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + hover(&mut h, pos2(5.0, 5.0)); } - // An ordinary value does take the hover accent there. - let area = value_rect(&h, "timeline.prop", ids.scale).expect("Scale"); - hover(&mut h, area.left_center() + vec2(6.0, 0.0)); - let colors = text_colors(&h, area); - assert!(colors.contains(&t.accent_hover), "an ordinary hovered value: {colors:?}"); } From 6bf9c87a1a5983c9affda53c4020ccd1123dda55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 19:07:25 +0200 Subject: [PATCH 0035/1386] Markers: the disk key keeps protected markers with a duration and drops a lone plain comp marker (#1083) pixel_form_with borrowed the comp unchanged when it held only comp markers and no layer markers, so a lone plain comp marker still reached the disk key. Tests: a lone comp marker keeps the key; a protected marker with a duration (and its duration) on a stretched precomp's source changes the key of the comp that nests it. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_disk_cache.rs | 39 +++++++++++++++++++++++++++ crates/project/src/lib.rs | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs index 9c4daa954..fc136c06e 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests_disk_cache.rs @@ -156,3 +156,42 @@ fn markers_keep_the_content_key_unless_an_expression_reads_them() { s.execute("comp.addMarker", json!({"time": 1.0})).unwrap(); assert_ne!(key(&s), k1, "a marker read by an expression"); } + +/// A comp marker alone (no layer marker beside it) keeps the key too. +#[test] +fn a_lone_comp_marker_keeps_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0); +} + +/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so +/// changing it changes the key of the comp that nests it; a plain marker beside it does not. +#[test] +fn protected_markers_with_a_duration_change_the_content_key() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("layer.timeStretch", json!({"percent": 200})).unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap(); + assert_eq!(key(&s), k0, "a plain marker"); + s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap(); + let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap(); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0, "a protected marker with a duration"); + s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap(); + assert_ne!(key(&s), k1, "its duration"); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 9ab402d9d..4f6964740 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -931,7 +931,7 @@ impl Comp { if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) - && (with_markers || (self.markers.iter().all(|m| !is_timing_marker(m)) && self.layers.iter().all(|l| l.markers.is_empty()))) + && (with_markers || (self.markers.iter().all(is_timing_marker) && self.layers.iter().all(|l| l.markers.is_empty()))) { return std::borrow::Cow::Borrowed(self); } From ec29d78ca2717cbbdc6e8392b3e33b4a458ac6ac Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:25:42 -0400 Subject: [PATCH 0036/1386] GPU: working textures are labelled with their size (refs #1319) --- crates/gpu/src/context.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 51221abc3..bd4b3c55c 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -850,8 +850,9 @@ impl GpuContext { } fn texture(&self, w: u32, h: u32) -> wgpu::Texture { + // Named with its size, so a GPU error about it says which texture failed (#1319). self.device.create_texture(&wgpu::TextureDescriptor { - label: None, + label: Some(&format!("working image {w}×{h}")), size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, From f382e5a5d6de2d797295ac96024728b43eb9b889 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:32:14 -0400 Subject: [PATCH 0037/1386] UI: an expression on separated X Position turns only X red (#1306) With Separate Dimensions, X Position and Y Position are separate scalar properties. The new test puts an expression on X Position only and checks, in the Timeline and Properties panel and in both themes, that X is red and Y keeps the ordinary colour. It fails on main. --- crates/ui-egui/tests/ui_expression_values.rs | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ui-egui/tests/ui_expression_values.rs b/crates/ui-egui/tests/ui_expression_values.rs index 6c07b774e..b797e6cf6 100644 --- a/crates/ui-egui/tests/ui_expression_values.rs +++ b/crates/ui-egui/tests/ui_expression_values.rs @@ -271,3 +271,26 @@ fn a_dragged_expression_value_stays_red() { hover(&mut h, pos2(5.0, 5.0)); } } + +/// Separate Dimensions: X Position and Y Position are separate scalars, so an expression on X Position +/// turns only X red, in the Timeline and Properties panel and both themes; Y Position stays blue. +#[test] +fn separated_position_colours_each_dimension_on_its_own() { + let (mut h, ids) = setup(); + h.state_mut().session.execute("prop.separateDimensions", json!({"layer": ids.layer, "value": true})).unwrap(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| { + let s = &h.state().session; + s.active_comp().unwrap().layer(LayerId(ids.layer)).unwrap().props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid + }; + const LAYER: &[&str] = &[TIMELINE, PROPERTIES]; + let x = Checked { uid: uid(&h, "transform/positionX"), kind: Kind::Scalar, panels: LAYER }; + let y = Checked { uid: uid(&h, "transform/positionY"), kind: Kind::Scalar, panels: LAYER }; + set_expression(&mut h, &ids, x.uid, &json!({"expression": "value"})); + h.run_steps(3); + for theme in [ThemeKind::Dark, ThemeKind::Light] { + set_theme(&mut h, theme); + let t = h.state().tokens; + assert_all(&h, &[x], t.expr_text, &format!("{theme:?} X Position (expression)")); + assert_all(&h, &[y], t.hot_text, &format!("{theme:?} Y Position (none)")); + } +} From babdcd3b9f1d7e271cafc32bde68255e116089d6 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:36:04 -0400 Subject: [PATCH 0038/1386] GPU: the frame guard contains validation errors and drops the failed frame's textures (refs #1319) --- crates/gpu/src/context.rs | 12 ++++++++++++ crates/gpu/src/lib.rs | 27 +++++++++++++++++---------- crates/gpu/src/particles.rs | 7 +++++++ crates/gpu/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index bd4b3c55c..9e3d98365 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -980,6 +980,18 @@ impl GpuContext { s.clear(); } } + + /// After a failed frame: forget every texture and buffer kept for reuse, since any of them + /// may be one the device rejected (#1319). Later frames allocate afresh. + pub(crate) fn discard_kept(&self) { + self.clear_uploads(); + if let Ok(mut z) = self.zeros.lock() { + z.clear(); + } + if let Ok(mut c) = self.particle_states.lock() { + c.clear(); + } + } } /// Records GPU work for one render (a command encoder submitted at readback or the end). diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 252145c14..ee6970de1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -264,10 +264,13 @@ impl Gpu { } } - /// Run work in native thread-local out-of-memory and internal-error scopes. A failed - /// result is discarded and cached allocations are released; callers can retry with a fresh - /// CPU renderer. Some drivers report a failed allocation as an internal error rather than - /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. + /// Run work in native thread-local out-of-memory, internal-error and validation scopes. A + /// failed result is discarded and the textures and buffers kept for reuse are released; + /// callers can retry with a fresh CPU renderer. Some drivers report a failed allocation as an + /// internal error rather than out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), + /// so both count. A failed allocation leaves an invalid texture whose use is then a + /// validation error (`Texture::create_view`, #1319): caught here, it fails this frame only + /// instead of retiring the GPU compositor for the session as an uncaptured error. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { @@ -276,16 +279,20 @@ impl Gpu { { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); + let validation = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); let out = f(); + let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); - match internal.or(oom) { - Some(e) => { - self.ctx.clear_uploads(); - Err(e.to_string()) - } - None => self.ctx.check_health().map(|()| out), + // The allocation failure first: a validation error is often its consequence. + let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); + if errors.is_empty() { + return self.ctx.check_health().map(|()| out); } + // Its textures go back to the pool when dropped: drop them before emptying it. + drop(out); + self.ctx.discard_kept(); + Err(errors.join("; then: ")) } #[cfg(target_arch = "wasm32")] { diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 7934dee0c..0492e5a5d 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -33,6 +33,13 @@ pub(crate) struct Checkpoints { clock: u64, } +impl Checkpoints { + /// Forget every kept state (after a failed frame, whose buffers may be invalid). + pub(crate) fn clear(&mut self) { + self.list.clear(); + } +} + fn pipes(g: &GpuContext) -> &Pipes { g.particles.get_or_init(|| { let d = &g.device; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 696402d6a..2ccd438bc 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -746,6 +746,40 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } +/// A texture the device rejects, then used in the frame guard (its view: "Texture with '' label +/// is invalid", #1319), fails that frame only. The validation error doesn't reach the device's +/// handler (which retires the compositor for the session, and panics in tests), the frame's +/// textures don't go back to the pool, and the next frame renders on the GPU. +#[test] +fn a_rejected_texture_fails_the_frame_not_the_compositor() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1); + let err = g + .within_memory(|| { + let mut e = crate::context::Enc::new(&g.ctx); + let kept = e.image(64, 32); + let rejected = g.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("deliberately too wide"), + size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + (kept, rejected.create_view(&Default::default())) + }) + .expect_err("the rejected texture fails the frame"); + assert!(err.contains("deliberately too wide"), "{err}"); + assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); + let (hits, _) = g.ctx.pool_stats(); + let img = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(64, 32)).expect("the next frame renders"); + assert_eq!((img.width, img.height), (64, 32)); + assert_eq!(g.ctx.pool_stats().0, hits, "the failed frame's texture wasn't reused"); +} + /// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an /// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of /// panicking in `create_buffer_init`'s mapped range on a frame thread (#591). From 1b3a70a7bc93332f3b0827ed4c0be01ea9812edd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:43:33 -0400 Subject: [PATCH 0039/1386] GPU: discard_kept is public like clear_uploads, so wasm builds don't flag it unused (refs #1319) --- crates/gpu/src/context.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 9e3d98365..e220a146b 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -983,7 +983,7 @@ impl GpuContext { /// After a failed frame: forget every texture and buffer kept for reuse, since any of them /// may be one the device rejected (#1319). Later frames allocate afresh. - pub(crate) fn discard_kept(&self) { + pub fn discard_kept(&self) { self.clear_uploads(); if let Ok(mut z) = self.zeros.lock() { z.clear(); From 87d28ac6fe5f6170bd22b47d2e59aeaeb6549839 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:43:33 -0400 Subject: [PATCH 0040/1386] GPU: a frame the device rejected isn't reported as out of memory (refs #1319) --- crates/gpu/src/lib.rs | 35 ++++++++++++++++++++++++++++++----- crates/gpu/src/tests.rs | 2 +- crates/ui-egui/src/frames.rs | 9 +++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ee6970de1..08806b654 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -96,6 +96,29 @@ pub use wgpu; use crate::context::Enc; +/// Why [`Gpu::within_memory`] discarded a frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FrameError { + /// The GPU context is retired (device lost, a readback timed out, an uncaptured error). + Retired(String), + /// Out of video memory (or a driver reporting a failed allocation as an internal error), + /// with any validation errors it went on to cause. + Memory(String), + /// The device rejected a command with no allocation failure before it (an invalid texture + /// or parameters, #1319): a compositor bug, not memory pressure. + Validation(String), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::Retired(e) | FrameError::Memory(e) | FrameError::Validation(e) => f.write_str(e), + } + } +} + +impl std::error::Error for FrameError {} + /// The GPU compositor (cheap to clone; one device shared by all clones). #[derive(Clone)] pub struct Gpu { @@ -273,8 +296,8 @@ impl Gpu { /// instead of retiring the GPU compositor for the session as an uncaptured error. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. - pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { - self.ctx.check_health()?; + pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { + self.ctx.check_health().map_err(FrameError::Retired)?; #[cfg(not(target_arch = "wasm32"))] { let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); @@ -284,20 +307,22 @@ impl Gpu { let validation = pollster::block_on(validation.pop()); let internal = pollster::block_on(internal.pop()); let oom = pollster::block_on(oom.pop()); + let memory = oom.is_some() || internal.is_some(); // The allocation failure first: a validation error is often its consequence. let errors: Vec = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect(); if errors.is_empty() { - return self.ctx.check_health().map(|()| out); + return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired); } // Its textures go back to the pool when dropped: drop them before emptying it. drop(out); self.ctx.discard_kept(); - Err(errors.join("; then: ")) + let errors = errors.join("; then: "); + Err(if memory { FrameError::Memory(errors) } else { FrameError::Validation(errors) }) } #[cfg(target_arch = "wasm32")] { let out = f(); - self.ctx.check_health().map(|()| out) + self.ctx.check_health().map(|()| out).map_err(FrameError::Retired) } } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 2ccd438bc..f9d62b901 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -772,7 +772,7 @@ fn a_rejected_texture_fails_the_frame_not_the_compositor() { (kept, rejected.create_view(&Default::default())) }) .expect_err("the rejected texture fails the frame"); - assert!(err.contains("deliberately too wide"), "{err}"); + assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}"); assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); let (hits, _) = g.ctx.pool_stats(); let img = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(64, 32)).expect("the next frame renders"); diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..2d29dd0af 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -31,7 +31,7 @@ use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Pro use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{CancellableFootage, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; -use effectcraft_gpu::{DisplayFrame, Gpu}; +use effectcraft_gpu::{DisplayFrame, FrameError, Gpu}; use rayon::prelude::*; /// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of. @@ -1223,11 +1223,16 @@ impl Worker { /// A failed scoped attempt retries on CPU. Retired contexts cannot recover by reducing /// their budget; only a healthy device's allocation failure uses the upstream backoff. - fn gpu_attempt_failed(&self, gpu: &Gpu, e: &str) { + fn gpu_attempt_failed(&self, gpu: &Gpu, e: &FrameError) { if gpu.context().check_health().is_err() { log::warn!("the GPU context is retired ({e}); the frame renders on the CPU"); return; } + // Not memory pressure: keeping fewer GPU frames wouldn't help (#1319). + if let FrameError::Validation(_) = e { + log::warn!("the GPU rejected the frame ({e}); it renders on the CPU, the next frame tries the GPU again"); + return; + } log::warn!("the GPU ran out of memory ({e}); the frame renders on the CPU and fewer GPU frames are kept"); if let Ok(mut c) = self.cache.lock() { c.gpu_out_of_memory(); From 2e2d75f03a8f3ae0bb93cce5b12894abbb7f9aa7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:43:43 -0400 Subject: [PATCH 0041/1386] Docs: the GPU frame guard catches validation errors (refs #1319) --- docs/architecture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22a..bec75b3d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -345,7 +345,10 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. A frame whose render panics is +the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +validation errors (an invalid texture, often left by a failed allocation): that frame renders on +the CPU and the kept textures are freed, but the compositor stays on and, with no allocation +failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is rendered again on the CPU or released, never left in progress. Native readbacks and timing waits share a finite deadline; failure retires pending readbacks exactly once, and late results are discarded. The desktop executable and browser page own their shared device's error/loss handlers and forward From c979f585ac4cb3c456394dda151889af5a9e5846 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:47:22 -0400 Subject: [PATCH 0042/1386] Timeline: focus newly added expressions after Alt-click (refs #1072) Alt-clicking a stopwatch added an expression, but its editor needed another click before typing went into it. In After Effects the new expression's field takes the keyboard at once, its text selected. The pick whip's one-shot refocus (the editor takes the keyboard back after a reference goes in) is now an ExprFocus request for any property: which text to select, counted from the end, and the pass it was asked for in. An Alt-click that adds an expression asks for it with the whole text selected, so typing replaces it; the expression row is opened if it was closed and scrolled into view (it is added below the stopwatch's row, which can be the last one shown). The request is taken once, when the editor is drawn, and dropped if its expression doesn't appear or it isn't taken within a few passes, so it never takes the keyboard later; an Alt-click that removes the expression clears it. Tests (ui_inline_edit): typing straight after the Alt-click replaces the new expression; a field clicked right after keeps the keyboard; after removing it nothing has the keyboard; an expression added at the Timeline's bottom brings its editor into view with the keyboard. Both fail on main. --- crates/ui-egui/src/panels/timeline.rs | 58 +++++++++++++--- crates/ui-egui/tests/ui_inline_edit.rs | 92 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..72c071107 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -265,10 +265,25 @@ fn whip_into_id(prop: u64) -> egui::Id { egui::Id::new(("tl-pickwhip-into", prop)) } -/// After a pick whip into the expression being edited: its editor takes the focus back, the -/// cursor this many characters before the end (just after the reference). -fn expr_refocus_id(prop: u64) -> egui::Id { - egui::Id::new(("tl-expr-refocus", prop)) +/// An expression editor to take the keyboard when it is next drawn: its property, the text to +/// select as counts of characters before the end (so it fits the expression as it will be then) +/// and the pass it was asked for in. After a pick whip into the expression being edited, the +/// cursor goes just after the reference; after an Alt-click on a stopwatch adds an expression, +/// its whole text is selected, so typing replaces it. +#[derive(Clone, Copy, Debug)] +struct ExprFocus { + prop: u64, + select: [usize; 2], + pass: u64, +} + +fn expr_focus_id() -> egui::Id { + egui::Id::new("tl-expr-focus") +} + +fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass })); } #[derive(Clone, Debug)] @@ -1888,6 +1903,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); + // An expression editor asked to take the keyboard comes into view; a request whose + // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never + // takes the keyboard later). + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())) { + match rows.iter().position(|r| matches!(r.kind, RowKind::Expr { uid, .. } if uid == f.prop)) { + Some(i) if ctx.cumulative_pass_nr() <= f.pass.saturating_add(8) => { + let top: f32 = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); + let fits = (top + rows.get(i).map_or(rh, |r| row_height(r, rh)) - rows_rect.height()).max(0.0).min(top); + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(top).max(fits); + } + _ => ctx.data_mut(|d| d.remove::(expr_focus_id())), + } + } if scroll_to == Some(crate::state::ScrollTo::Layer) { // The topmost selected layer, in stack order. let sel = &app.session.state.selected_layers; @@ -2630,12 +2658,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); - if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { - // A pick whip just put a reference in: editing goes on in the new text, - // the cursor after the reference. + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) { + // Asked for once (a pick whip put a reference in, or an Alt-click added the + // expression): editing starts in the expression as it is now. + ctx.data_mut(|d| d.remove::(expr_focus_id())); buf = ex.text.clone(); + let n = buf.chars().count(); + let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end))); let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after))))); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b))); st.store(&ctx, edit_id); ctx.memory_mut(|m| m.request_focus(edit_id)); } @@ -2733,6 +2764,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.modifiers.alt) { if resp.clicked() { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); + if prop.expr.is_none() { + // Adding one: its editor opens, comes into view and takes the + // keyboard with the new expression selected (#1072). + app.ui.timeline.expr_closed.remove(uid); + focus_expression(&ctx, *uid, [usize::MAX, 0]); + } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) { + // Removing it: nothing is left to take the keyboard. + ctx.data_mut(|d| d.remove::(expr_focus_id())); + } } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); @@ -3057,7 +3097,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { params["expression"] = json!(text); params["range"] = json!([a, b]); let after = text.chars().count().saturating_sub(b); - ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after)); + focus_expression(&ctx, src_prop, [after, after]); } actions.push(("prop.pickWhip".into(), params)); } diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..7bc5505ed 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,95 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// A solid named `name` with its Transform group open in the Timeline; returns the layer, its +/// Opacity uid and its Rotation uid. +fn open_transform(h: &mut Harness<'_, EffectcraftApp>, name: &str) -> (u64, u64, u64) { + let (layer, opacity, rotation, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + (layer, opacity, rotation) +} + +fn expression(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(prop).unwrap().expr.as_ref().map(|e| e.text.clone()) +} + +fn commit_expression(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: Alt-clicking a stopwatch adds an expression and its editor takes the keyboard at once, +/// the new expression selected, so typing replaces it (it took another click). That happens +/// once: a field clicked next keeps the keyboard, and removing the expression with another +/// Alt-click leaves nothing to take it. +#[test] +fn alt_clicking_a_stopwatch_focuses_the_new_expression() { + let (mut h, _) = harness(); + let (layer, opacity, rotation) = open_transform(&mut h, "Plate"); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let stopwatch = |h: &Harness<'_, EffectcraftApp>, prop: u64| center(h, &format!("timeline.prop.{prop}.stopwatch")); + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + assert!(expression(&h, layer, opacity).is_some(), "an expression was added"); + type_text(&mut h, "50"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("50"), "typing replaced the new expression"); + // A field clicked right after an Alt-click keeps the keyboard. + let at = stopwatch(&h, rotation); + click_with(&mut h, at, alt); + let added = expression(&h, layer, rotation).expect("an expression on Rotation"); + let p = center(&h, "timeline.timecode"); + click(&mut h, p, 2); + type_text(&mut h, "0:00:01:00"); + key(&mut h, Key::Enter); + let frame = h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time()); + assert_eq!((frame, expression(&h, layer, rotation)), (24, Some(added)), "the time took the typing, not the expression"); + // Alt-click again removes the expression; nothing takes the keyboard after it. + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + h.run_steps(5); + assert_eq!(expression(&h, layer, opacity), None); + assert!(!h.ctx.egui_wants_keyboard_input(), "nothing has the keyboard"); +} + +/// #1072: an expression added at the bottom of the Timeline brings its editor into view to take +/// the keyboard (its row is added below the last one shown). +#[test] +fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() { + let (mut h, _) = harness(); + let (layer, opacity, _) = open_transform(&mut h, "Bottom"); + for i in 0..40 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + h.run_steps(3); + // Scroll to the end (the layer is at the bottom of the stack), then until Opacity is the + // last row shown. + h.state_mut().ui.timeline.scroll_y = 1.0e6; + h.run_steps(3); + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let bottom = rows[1] + rows[3]; + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + h.state_mut().ui.timeline.scroll_y += row[1] + row[3] - bottom; + h.run_steps(3); + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + assert!((row[1] + row[3] - bottom).abs() < 1.0, "Opacity is at the bottom: {row:?}"); + let at = center(&h, &format!("timeline.prop.{opacity}.stopwatch")); + click_with(&mut h, at, Modifiers { alt: true, ..Modifiers::NONE }); + let editor = h.state().auto.find(&format!("timeline.prop.{opacity}.expression")).expect("the editor is drawn").rect; + assert!(editor[1] >= rows[1] - 0.5 && editor[1] + editor[3] <= bottom + 0.5, "the editor is in view: {editor:?} in {rows:?}"); + type_text(&mut h, "25"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25")); +} From 864b9e4ede6bde47e0577264df724e3517417d90 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:12:15 -0400 Subject: [PATCH 0043/1386] GPU: no texture of a failed frame returns to the pool, even one out or waiting on another encoder (refs #1319) --- crates/gpu/src/context.rs | 30 +++++++++++++++++++++++---- crates/gpu/src/tests.rs | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e220a146b..f598df4af 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -73,6 +73,8 @@ struct Lease { pool: Weak>, /// The [`Enc`] that took it (see [`Pool::owned`]). owner: u64, + /// The pool's [`Pool::generation`] when it was taken. + generation: u64, } impl std::fmt::Debug for Lease { @@ -86,7 +88,7 @@ impl Drop for Lease { if let Some(pool) = self.pool.upgrade() && let Ok(mut p) = pool.lock() { - p.release_owned(self.texture.clone(), self.owner); + p.release_owned(self.texture.clone(), self.owner, self.generation); } } } @@ -117,6 +119,8 @@ pub(crate) struct Pool { /// working texture of another `Enc` before it is gone. owned: HashMap>>, next_owner: u64, + /// Raised by [`Pool::discard`]: a texture taken before it never comes back to the pool. + generation: u64, } /// In-`Enc` texture reuse ([`Pool::owned`]); tests turn it off to compare. @@ -207,7 +211,10 @@ impl Pool { } } - fn release_owned(&mut self, t: wgpu::Texture, owner: u64) { + fn release_owned(&mut self, t: wgpu::Texture, owner: u64, generation: u64) { + if generation != self.generation { + return; + } if let Some(m) = self.owned.get_mut(&owner) { m.entry((t.width(), t.height())).or_default().push(t); return; @@ -237,6 +244,18 @@ impl Pool { self.free.clear(); self.bytes = 0; } + + /// After a failed frame (#1319): drop every kept texture, the free ones and those waiting + /// for an open encoder or kept by one, and the textures still out never return. The + /// encoders' bookkeeping stays, so reuse remains ordered. + fn discard(&mut self) { + self.clear(); + self.released.clear(); + for kept in self.owned.values_mut() { + kept.clear(); + } + self.generation = self.generation.wrapping_add(1); + } } /// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`). @@ -867,10 +886,10 @@ impl GpuContext { /// one is zeroed. fn pooled(&self, w: u32, h: u32, owner: u64) -> (GpuImage, bool) { let (w, h) = (w.max(1), h.max(1)); - let reused = self.pool.lock().ok().and_then(|mut p| p.take_owned(owner, w, h).or_else(|| p.take(w, h))); + let (reused, generation) = self.pool.lock().map(|mut p| (p.take_owned(owner, w, h).or_else(|| p.take(w, h)), p.generation)).unwrap_or((None, u64::MAX)); let was_reused = reused.is_some(); let texture = reused.unwrap_or_else(|| self.texture(w, h)); - let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner }); + let lease = Arc::new(Lease { texture: texture.clone(), pool: Arc::downgrade(&self.pool), owner, generation }); (GpuImage { texture, width: w, height: h, _lease: Some(lease), levels: None }, was_reused) } @@ -985,6 +1004,9 @@ impl GpuContext { /// may be one the device rejected (#1319). Later frames allocate afresh. pub fn discard_kept(&self) { self.clear_uploads(); + if let Ok(mut p) = self.pool.lock() { + p.discard(); + } if let Ok(mut z) = self.zeros.lock() { z.clear(); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index f9d62b901..a5df345d5 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -780,6 +780,49 @@ fn a_rejected_texture_fails_the_frame_not_the_compositor() { assert_eq!(g.ctx.pool_stats().0, hits, "the failed frame's texture wasn't reused"); } +/// A texture the device rejected (deliberately too wide), made outside any frame under a scope +/// of its own: the view a frame then makes of it is the reported validation error. +fn rejected_texture(g: &Gpu) -> wgpu::Texture { + let scope = g.ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1); + let t = g.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("deliberately too wide"), + size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + assert!(pollster::block_on(scope.pop()).is_some(), "the device rejected the texture"); + t +} + +/// No texture of a failed frame is reused: not one released while another encoder is still +/// open (it waits for that encoder), nor one taken before the failure and handed back after. +#[test] +fn a_failed_frame_returns_no_texture_to_the_pool() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let earlier = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(48, 16)).expect("an earlier frame"); + let mut other = crate::context::Enc::new(&g.ctx); + other.encoder(); + g.within_memory(|| (crate::context::Enc::new(&g.ctx).image(64, 32), rejected.create_view(&Default::default()))).expect_err("the frame fails"); + drop(earlier); + drop(other); + let (hits, _) = g.ctx.pool_stats(); + let (a, b) = g + .within_memory(|| { + let mut e = crate::context::Enc::new(&g.ctx); + (e.image(64, 32), e.image(48, 16)) + }) + .expect("the next frame"); + assert_eq!(((a.width, a.height), (b.width, b.height)), ((64, 32), (48, 16))); + assert_eq!(g.ctx.pool_stats().0, hits, "no texture of the failed frame was reused"); +} + /// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an /// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of /// panicking in `create_buffer_init`'s mapped range on a frame thread (#591). From 3dca18595894c8079e993f60796cca1d8676a0b1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:12:16 -0400 Subject: [PATCH 0044/1386] GPU: test that the frame after a rejected view composites on the GPU like the CPU (refs #1319) --- crates/gpu/src/tests.rs | 56 ++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index a5df345d5..a01618fa1 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -746,40 +746,6 @@ fn within_memory_returns_the_work_when_there_is_room() { assert_eq!((img.width, img.height), (64, 32)); } -/// A texture the device rejects, then used in the frame guard (its view: "Texture with '' label -/// is invalid", #1319), fails that frame only. The validation error doesn't reach the device's -/// handler (which retires the compositor for the session, and panics in tests), the frame's -/// textures don't go back to the pool, and the next frame renders on the GPU. -#[test] -fn a_rejected_texture_fails_the_frame_not_the_compositor() { - hold_gpu_lock(); - let Some(g) = Gpu::headless() else { return }; - let too_wide = g.ctx.device.limits().max_texture_dimension_2d.saturating_add(1); - let err = g - .within_memory(|| { - let mut e = crate::context::Enc::new(&g.ctx); - let kept = e.image(64, 32); - let rejected = g.ctx.device.create_texture(&wgpu::TextureDescriptor { - label: Some("deliberately too wide"), - size: wgpu::Extent3d { width: too_wide, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - (kept, rejected.create_view(&Default::default())) - }) - .expect_err("the rejected texture fails the frame"); - assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}"); - assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); - let (hits, _) = g.ctx.pool_stats(); - let img = g.within_memory(|| crate::context::Enc::new(&g.ctx).image(64, 32)).expect("the next frame renders"); - assert_eq!((img.width, img.height), (64, 32)); - assert_eq!(g.ctx.pool_stats().0, hits, "the failed frame's texture wasn't reused"); -} - /// A texture the device rejected (deliberately too wide), made outside any frame under a scope /// of its own: the view a frame then makes of it is the reported validation error. fn rejected_texture(g: &Gpu) -> wgpu::Texture { @@ -799,6 +765,28 @@ fn rejected_texture(g: &Gpu) -> wgpu::Texture { t } +/// A frame that makes a view of an invalid texture ("Texture with '' label is invalid", #1319) +/// fails alone: the validation error doesn't reach the device's handler (which retires the +/// compositor for the session, and panics in tests), and the next frame composites on the GPU, +/// read back, like the CPU. +#[test] +fn a_view_of_an_invalid_texture_fails_the_frame_not_the_compositor() { + hold_gpu_lock(); + let Some(g) = Gpu::headless() else { return }; + let rejected = rejected_texture(&g); + let err = g.within_memory(|| rejected.create_view(&Default::default())).expect_err("the frame fails"); + assert!(matches!(&err, crate::FrameError::Validation(e) if e.contains("deliberately too wide")), "{err:?}"); + assert!(g.ctx.check_health().is_ok(), "the compositor stays on: {:?}", g.ctx.check_health()); + let s = blend_scene(BitDepth::Bpc32, BlendMode::Normal); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.within_memory(|| g.render(&r, s.cid, Tick::ZERO)).expect("the next frame isn't rejected").expect("the GPU renders it"); + let mut d = diff(&cpu, &img, tolerance(BitDepth::Bpc32)); + d.quantized = false; + check("after a rejected frame", Some(d), 0.0); +} + /// No texture of a failed frame is reused: not one released while another encoder is still /// open (it waits for that encoder), nor one taken before the failure and handed back after. #[test] From c1287001c50fb70ca34a6fbd420a290de84e3aab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:05:00 +0200 Subject: [PATCH 0045/1386] #1335: AccessKit audit test; names, roles and keyboard operation for Effects & Presets, Settings and Render Queue ui_accessibility_audit walks the AccessKit tree and counts unnamed or roleless controls per panel (zero required for Project, Effects & Presets, Settings and the Render Queue; a ratchet for the rest). Rows are named tree items with selected / open state, the page list is a list with positions, controls carry label and value, the lists take the arrow keys, Home / End, Enter and Space, popup lists take the focus when they open. ui_contrast checks the theme tokens against WCAG 2. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/a11y.rs | 210 ++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 18 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/effects_presets.rs | 343 ++++++++++++++-- crates/ui-egui/src/panels/render_queue.rs | 139 ++++++- crates/ui-egui/src/panels/rq_templates.rs | 4 + crates/ui-egui/src/panels/settings.rs | 69 +++- crates/ui-egui/src/widgets.rs | 84 +++- .../ui-egui/tests/ui_accessibility_audit.rs | 377 ++++++++++++++++++ crates/ui-egui/tests/ui_contrast.rs | 92 +++++ .../tests/ui_effects_presets_keyboard.rs | 271 +++++++++++++ .../ui-egui/tests/ui_render_queue_keyboard.rs | 226 +++++++++++ crates/ui-egui/tests/ui_settings_keyboard.rs | 113 ++++++ docs/gaps.md | 7 +- docs/ui-parity.md | 7 +- 16 files changed, 1899 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/a11y.rs create mode 100644 crates/ui-egui/tests/ui_accessibility_audit.rs create mode 100644 crates/ui-egui/tests/ui_contrast.rs create mode 100644 crates/ui-egui/tests/ui_effects_presets_keyboard.rs create mode 100644 crates/ui-egui/tests/ui_render_queue_keyboard.rs create mode 100644 crates/ui-egui/tests/ui_settings_keyboard.rs diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs new file mode 100644 index 000000000..cca126876 --- /dev/null +++ b/crates/ui-egui/src/a11y.rs @@ -0,0 +1,210 @@ +//! Accessibility helpers (docs/gaps.md gap 25): names, roles and states for the widgets the app +//! paints itself, and the small pieces of list keyboard handling the panels share. +//! +//! egui builds an AccessKit node for every widget that can take the keyboard focus, but a widget +//! made with `ui.interact` has no name and no role until the panel says what it is. These helpers +//! write that onto the widget's node *after* the widget is made each frame (egui rebuilds the +//! tree every pass), and keep whatever egui already put there (its role for a `DragValue`, the +//! numeric value of a `Slider`). +//! +//! Names are the same translated strings the panel draws (`i18n::tr`), never hard-coded English. +//! `tests/ui_accessibility_audit.rs` walks the tree and fails on an unnamed control in the panels +//! that have had their pass. + +use egui::accesskit::{HasPopup, Role, Toggled}; +use egui::{Context, Id, Response}; + +/// Set `f` on `r`'s AccessKit node (nothing when AccessKit is off). egui only fills a node's +/// bounds and actions for widgets that can take the keyboard focus; a widget that cannot (the +/// non-current rows of a list) still needs both, or assistive technology cannot place or click it. +fn node(r: &Response, f: impl FnOnce(&mut egui::accesskit::Node)) { + r.ctx.accesskit_node_builder(r.id, |n| { + n.set_bounds(egui::accesskit::Rect { x0: r.rect.min.x.into(), y0: r.rect.min.y.into(), x1: r.rect.max.x.into(), y1: r.rect.max.y.into() }); + if r.sense.is_focusable() { + n.add_action(egui::accesskit::Action::Focus); + } + if r.sense.senses_click() { + n.add_action(egui::accesskit::Action::Click); + } + f(n); + }); +} + +/// Give `r`'s node a role and leave its name to the caller. +pub(crate) fn role(r: &Response, role: Role) { + node(r, |n| n.set_role(role)); +} + +/// Give `r`'s node a role and a name. +pub(crate) fn role_name(r: &Response, role: Role, label: &str) { + node(r, |n| { + n.set_role(role); + n.set_label(label); + }); +} + +/// Name `r`'s node (`label`), keeping the role egui gave it (spin button, slider, text input). +pub(crate) fn name(r: &Response, label: &str) { + node(r, |n| n.set_label(label)); +} + +/// Name `r`'s node and give it a current value (a spin button's or a text field's text). +pub(crate) fn name_value(r: &Response, label: &str, value: &str) { + node(r, |n| { + n.set_label(label); + n.set_value(value); + }); +} + +/// Set `r`'s checked state (a check box, a switch, a toggle button). +pub(crate) fn checked(r: &Response, on: bool) { + node(r, |n| n.set_toggled(if on { Toggled::True } else { Toggled::False })); +} + +/// Set `r`'s open state (a twirl, a disclosure). +pub(crate) fn expanded(r: &Response, open: bool) { + node(r, |n| n.set_expanded(open)); +} + +/// A push button; `enabled` false marks it disabled for assistive technology (it stays visible). +pub(crate) fn button(r: &Response, label: &str, enabled: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A button that opens a menu or popup: `open` is whether it is open now. +pub(crate) fn menu_button(r: &Response, label: &str, open: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + n.set_has_popup(HasPopup::Menu); + n.set_expanded(open); + }); +} + +/// A button that shows or hides something (a twirl): `expanded` is the state it leads to from. +pub(crate) fn disclosure(r: &Response, label: &str, expanded: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + n.set_expanded(expanded); + }); +} + +/// A check box. +pub(crate) fn check_box(r: &Response, label: &str, checked: bool, enabled: bool) { + node(r, |n| { + n.set_role(Role::CheckBox); + n.set_label(label); + n.set_toggled(if checked { Toggled::True } else { Toggled::False }); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A drop-down: `label` says what it sets and `value` is what it is set to. +pub(crate) fn combo_box(r: &Response, label: &str, value: &str, enabled: bool) { + node(r, |n| { + n.set_role(Role::ComboBox); + n.set_label(label); + n.set_value(value); + n.set_has_popup(HasPopup::Menu); + n.set_expanded(false); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A row of a tree: selected, its depth (`level`, 1 at the top), whether it is open (`None` for a +/// leaf) and its place among its siblings. +pub(crate) fn tree_item(r: &Response, label: &str, selected: bool, level: usize, expanded: Option) { + node(r, |n| { + n.set_role(Role::TreeItem); + n.set_label(label); + n.set_selected(selected); + n.set_level(level); + if let Some(open) = expanded { + n.set_expanded(open); + } + }); +} + +/// An entry of a flat list: `position` is its 1-based place among `size`. +pub(crate) fn list_item(r: &Response, label: &str, selected: bool, position: usize, size: usize) { + node(r, |n| { + n.set_role(Role::ListItem); + n.set_label(label); + n.set_selected(selected); + n.set_position_in_set(position); + n.set_size_of_set(size); + }); +} + +/// An entry of a menu or popup list; `checked` is `Some` for an entry that is on or off. +pub(crate) fn menu_item(r: &Response, label: &str, checked: Option) { + node(r, |n| { + n.set_label(label); + match checked { + Some(on) => { + n.set_role(Role::MenuItemCheckBox); + n.set_toggled(if on { Toggled::True } else { Toggled::False }); + } + None => n.set_role(Role::MenuItem), + } + }); +} + +/// A text field with a name (and its text as the value). +pub(crate) fn text_input(r: &Response, label: &str, text: &str) { + node(r, |n| { + n.set_role(Role::TextInput); + n.set_label(label); + n.set_value(text); + }); +} + +/// A progress bar: `fraction` 0..=1 and the sentence it shows. +pub(crate) fn progress(r: &Response, label: &str, fraction: f32) { + node(r, |n| { + n.set_role(Role::ProgressIndicator); + n.set_label(label); + n.set_min_numeric_value(0.0); + n.set_max_numeric_value(1.0); + n.set_numeric_value(f64::from(fraction.clamp(0.0, 1.0))); + }); +} + +// ------------------------------------------------------------------------------------------ +// List keyboard handling + +/// Ask for the keyboard focus on widget `id` once it is drawn (a list moves its focus to the row +/// the arrow keys reached, which may not be focusable, or drawn, until the next pass). `key` +/// names the list. +pub(crate) fn focus_next_pass(ctx: &Context, key: &str, id: Id) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(Id::new(("a11y-pending-focus", key)), (id, pass))); +} + +/// True once, for the widget `focus_next_pass` named for list `key`: the caller then requests the +/// focus. A request nobody answers within a few passes is dropped. +pub(crate) fn take_pending_focus(ctx: &Context, key: &str, id: Id) -> bool { + let slot = Id::new(("a11y-pending-focus", key)); + let now = ctx.cumulative_pass_nr(); + let Some((want, at)) = ctx.data(|d| d.get_temp::<(Id, u64)>(slot)) else { return false }; + if now > at.saturating_add(4) { + ctx.data_mut(|d| d.remove::<(Id, u64)>(slot)); + return false; + } + if want != id || now == at { + return false; + } + ctx.data_mut(|d| d.remove::<(Id, u64)>(slot)); + true +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..bced4dca5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub(crate) mod a11y; pub mod audio; pub mod automation; pub mod bench; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..6006be46e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -253,9 +253,9 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { /// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`, /// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in -/// they move by fractions of a comp pixel. From any panel except Project (which owns the -/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or -/// menu open, or with mask points selected. +/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and +/// Render Queue (which own the arrow keys for their own keyboard navigation, #961); not while +/// typing, with a dialog or menu open, or with mask points selected. fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { Some(side) => (side, 10.0), @@ -269,7 +269,11 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; - if app.ui.focused == PanelKind::Project || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() { + if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) + || st.selected_layers.is_empty() + || st.text_edit.is_some() + || !st.selected_vertices.is_empty() + { return Ok(Value::Null); } // Screen pixels per comp pixel. @@ -1915,8 +1919,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog.is_some() { continue; } - // Enter in the Project panel renames the selected item (handled by the panel). - if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project { + // Enter in the Project panel renames the selected item (handled by the panel); in Effects + // & Presets and the Render Queue it applies or opens the row (also handled by the panel), + // not the layer rename. + if key == egui::Key::Enter && !mods.any() && matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) { continue; } // The Project panel has its own selection; a previously selected Timeline layer diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..0c193e9bf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -205,7 +205,7 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // The id stays keyed on the untranslated source, so switching language cannot move it. let head = ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); let row = Rect::from_x_y_ranges(ui.max_rect().x_range(), head.rect.y_range()); - let grip = ui.interact(row, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + let grip = ui.interact(row, id.with("grip"), Sense::DRAG).on_hover_cursor(egui::CursorIcon::Grab); if grip.dragged() { let moved = (offset + grip.drag_delta()).clamp(-room, room); ui.ctx().data_mut(|d| d.insert_temp(offset_id, moved)); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..48e6ad43e 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -9,6 +9,7 @@ use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; +use crate::a11y; use crate::icons::{self, Icon}; use crate::panels::DragPayload; use crate::theme::Tokens; @@ -105,6 +106,7 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); } +#[derive(Clone)] enum Apply { Effect(String), TextPreset(String), @@ -113,6 +115,63 @@ enum Apply { PresetFile(String), } +/// One row of the list as drawn this pass, including the rows scrolled out of view: what the +/// arrow keys, Home / End and Enter work on (the rows are painted by hand, so this is the list). +struct NavRow { + id: egui::Id, + /// The favourite star's widget, for an effect row. + star: Option, + parent: Option, + /// Where the row starts in the scrolled content. + top: f32, + /// A folder: (its key, open, forced open by a search). + folder: Option<(String, bool, bool)>, + /// What Enter applies, for an effect or preset row. + apply: Option, +} + +/// The list's keyboard state: the rows drawn, and the row the keys work from (the "cursor": +/// selected, and the one Tab stop of the list). +struct Nav { + rows: Vec, + /// The open folders on the way down to the row being drawn, by level. + stack: Vec, + cursor: Option, + /// The scroll offset and the list's top, to place rows in the content. + offset: f32, + list_top: f32, +} + +impl Nav { + /// The row `id` is the list's Tab stop: the cursor row, or the first row while there is none. + fn tab_stop(&self, id: egui::Id) -> bool { + match self.cursor { + Some(c) => c == id, + None => self.rows.is_empty(), + } + } + + /// Record the row drawn at `y`; returns whether it is the cursor row. + fn push(&mut self, id: egui::Id, y: f32, level: usize, folder: Option<(String, bool, bool)>, apply: Option) -> bool { + let parent = level.checked_sub(2).and_then(|i| self.stack.get(i)).copied(); + if folder.is_some() { + self.stack.truncate(level.saturating_sub(1)); + self.stack.push(id); + } + self.rows.push(NavRow { id, star: None, parent, top: y - self.list_top + self.offset, folder, apply }); + self.cursor == Some(id) + } +} + +fn cursor_id() -> egui::Id { + egui::Id::new("fx-cursor") +} + +/// The accessible name of a folder: what the row shows, in the interface language. +fn folder_name(label: &str) -> &str { + tr(label) +} + /// A folder row; returns whether it is open. #[allow(clippy::too_many_arguments)] fn folder_row( @@ -121,8 +180,10 @@ fn folder_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, key: &str, label: &str, + level: usize, indent: f32, forced_open: bool, icon: Option, @@ -130,12 +191,18 @@ fn folder_row( let t = app.tokens; let open = forced_open || app.ui.effects_open.contains(key); let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new(("fxcat", key)); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, level, Some((key.to_string(), open, forced_open)), None); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return open; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click()); - if resp.hovered() { + let shown = folder_name(label); + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0)); @@ -146,18 +213,44 @@ fn folder_row( Some(i) => icons::paint(lp, ic, i, t.text_dim), None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)), } - lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); + lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.category.{key}"), r, label); - if resp.clicked() && !forced_open { - if open { - app.ui.effects_open.remove(key); - } else { - app.ui.effects_open.insert(key.to_string()); + a11y::tree_item(&resp, shown, selected, level, Some(open)); + row_focus(app, ui, lp, &resp, r, id); + if resp.clicked() { + nav_click(ui.ctx(), app, id); + if !forced_open { + if open { + app.ui.effects_open.remove(key); + } else { + app.ui.effects_open.insert(key.to_string()); + } } } open } +/// What every row does with the keyboard focus: the focus ring, the panel taking the focus, the +/// list owning its keys, and moving the focus here when the arrow keys asked for it. +fn row_focus(app: &mut EffectcraftApp, ui: &egui::Ui, lp: &egui::Painter, resp: &egui::Response, r: Rect, id: egui::Id) { + let ctx = ui.ctx(); + if a11y::take_pending_focus(ctx, "fx", id) { + resp.request_focus(); + } + if resp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + } + if resp.has_focus() { + lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, app.tokens.accent), egui::StrokeKind::Inside); + } +} + +/// A click on a row moves the cursor there and gives the panel the keyboard. +fn nav_click(ctx: &egui::Context, app: &mut EffectcraftApp, id: egui::Id) { + ctx.data_mut(|d| d.insert_temp(cursor_id(), id)); + app.ui.focused = crate::dock::PanelKind::EffectsPresets; +} + /// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge). #[allow(clippy::too_many_arguments)] fn effect_row( @@ -166,7 +259,9 @@ fn effect_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, e: &'static effectcraft_engine::effects::EffectSpec, + level: usize, indent: f32, query: &str, auto_prefix: &str, @@ -175,13 +270,22 @@ fn effect_row( let t = app.tokens; let ctx = ui.ctx().clone(); let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new((auto_prefix, e.id)); + let star_id = egui::Id::new((auto_prefix, e.id, "star")); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, level, None, Some(Apply::Effect(e.id.to_string()))); + if let Some(row) = nav.rows.last_mut() { + row.star = Some(star_id); + } *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; } let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0)); - let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag()); - if resp.hovered() { + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim); @@ -193,14 +297,23 @@ fn effect_row( lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint); } app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name); - // Favourite star (always shown when starred, else on hover). + a11y::tree_item(&resp, &shown, selected, level, None); + row_focus(app, ui, lp, &resp, r, id); + // Favourite star (always shown when starred, else on hover or with the keyboard focus). let fav = app.ui.effects_favorites.contains(e.id); - let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click()); - if fav || resp.hovered() || sresp.hovered() { + let sresp = ui.interact(star.intersect(list), star_id, if tab { Sense::click() } else { Sense::CLICK }); + if fav || resp.hovered() || sresp.hovered() || sresp.has_focus() { let gold = Color32::from_rgb(0xe8, 0xc0, 0x40); filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint }); } app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" }); + a11y::button(&sresp, &format!("{}: {shown}", tr(if fav { "Remove from favorites" } else { "Add to favorites" })), true); + if sresp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + } + if sresp.has_focus() { + widgets::focus_outline(ui, &sresp, &t); + } if sresp.clicked() { if fav { app.ui.effects_favorites.remove(e.id); @@ -209,6 +322,9 @@ fn effect_row( } return; } + if resp.clicked() { + nav_click(&ctx, app, id); + } if resp.double_clicked() { *apply = Some(Apply::Effect(e.id.to_string())); } @@ -225,6 +341,7 @@ fn preset_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, name: &str, auto_id: &str, query: &str, @@ -233,22 +350,146 @@ fn preset_row( ) { let t = app.tokens; let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new(("fxpreset", auto_id)); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, 3, None, Some(on_apply.clone())); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); - if resp.hovered() { + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + a11y::tree_item(&resp, name, selected, 3, None); + row_focus(app, ui, lp, &resp, r, id); + if resp.clicked() { + nav_click(ui.ctx(), app, id); + } if resp.double_clicked() { *apply = Some(on_apply); } } +/// The list's keys. With a row focused (Tab, a screen reader): Up / Down move between rows, +/// Home / End go to the first / last, Page Up / Page Down a page, Right opens a folder (then steps +/// into it) and Left closes it (then steps out to it), Enter and Space apply the row or open / +/// close the folder. With the mouse's last click in the panel and no widget focused, the arrows +/// and Enter work the same, so the list is operable without a mouse either way. The focus follows +/// the cursor, so a screen reader reads each row as it is reached. Returns what to apply. +fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rect) -> Option { + use egui::Key; + // A cursor whose row is gone (a search hid it): the first row is the Tab stop again. + if let Some(c) = nav.cursor + && !nav.rows.iter().any(|r| r.id == c) + { + ctx.data_mut(|d| d.remove::(cursor_id())); + ctx.request_repaint(); + } + let focused = ctx.memory(|m| m.focused()); + let on_row = focused.is_some_and(|f| nav.rows.iter().any(|r| r.id == f || r.star == Some(f))); + let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::EffectsPresets; + if (!on_row && !by_mouse) || app.dialog.is_some() || widgets::any_menu_open(ctx) { + return None; + } + let keys: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect() + }); + let page = ((list.height() / ROW_H).floor() as usize).saturating_sub(1).max(1); + // The row with the keyboard focus is where the keys start (the first Tab lands on a row + // before anything is selected); otherwise the cursor. + let focus_row = focused.and_then(|f| nav.rows.iter().find(|r| r.id == f || r.star == Some(f))).map(|r| r.id); + let mut cursor = focus_row.or_else(|| nav.cursor.filter(|c| nav.rows.iter().any(|r| r.id == *c))); + let mut apply = None; + let mut moved = false; + for key in keys { + let at = cursor.and_then(|c| nav.rows.iter().position(|r| r.id == c)); + let last = nav.rows.len().checked_sub(1)?; + let to = match key { + Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))), + Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))), + Key::Home if on_row => Some(0), + Key::End if on_row => Some(last), + Key::PageDown if on_row => Some(at.map_or(0, |a| a.saturating_add(page).min(last))), + Key::PageUp if on_row => Some(at.map_or(last, |a| a.saturating_sub(page))), + Key::ArrowRight | Key::ArrowLeft | Key::Enter | Key::Space if at.is_some() => { + let row = at.and_then(|a| nav.rows.get(a))?; + let toggle = |app: &mut EffectcraftApp, open: bool| { + if let Some((k, _, forced)) = &row.folder + && !forced + { + if open { + app.ui.effects_open.insert(k.clone()); + } else { + app.ui.effects_open.remove(k); + } + } + }; + match (key, &row.folder) { + (Key::ArrowRight, Some((_, false, _))) => { + toggle(app, true); + None + } + (Key::ArrowRight, Some((_, true, _))) => at.map(|a| a.saturating_add(1).min(last)), + (Key::ArrowLeft, Some((_, true, false))) => { + toggle(app, false); + None + } + (Key::ArrowLeft, _) => row.parent.and_then(|p| nav.rows.iter().position(|r| r.id == p)), + (Key::Enter, Some((_, open, _))) | (Key::Space, Some((_, open, _))) if on_row || key == Key::Enter => { + toggle(app, !*open); + None + } + (Key::Enter, None) => { + apply = row.apply.clone(); + None + } + (Key::Space, None) if on_row => { + apply = row.apply.clone(); + None + } + _ => None, + } + } + _ => None, + }; + if let Some(to) = to + && let Some(row) = nav.rows.get(to) + { + cursor = Some(row.id); + moved = true; + } + } + if moved && let Some(c) = cursor { + ctx.data_mut(|d| d.insert_temp(cursor_id(), c)); + // Scroll the row into view. + if let Some(row) = nav.rows.iter().find(|r| r.id == c) { + widgets::PanelScroll::reveal(ctx, egui::Id::new("fx-scroll"), row.top, row.top + ROW_H, list.height()); + } + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + if on_row { + a11y::focus_next_pass(ctx, "fx", c); + } + ctx.request_repaint(); + } + // The arrows are the list's, not egui's spatial focus moves. + if on_row { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + apply +} + /// User preset files, rescanned every few seconds (and on Refresh List). fn user_presets(ctx: &egui::Context, force: bool) -> Vec { let id = egui::Id::new("fx-user-presets"); @@ -276,13 +517,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + let search_hint = tr("Search effects and presets"); + let search = widgets::search_field(ui, sr, &mut q, search_hint, &t); + a11y::text_input(&search, search_hint, &q); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); // Contents menu. let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0)); let mpop = egui::Id::new("fx-contents-menu"); - if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() { + let menu_resp = widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")); + a11y::menu_button(&menu_resp, tr("Effects & Presets options"), widgets::popup_is_open(ui, mpop)); + if menu_resp.clicked() { widgets::open_popup(ui, mpop); } app.auto.add("effects.menu", mr, "Effects & Presets options"); @@ -314,6 +559,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.separator(); } else { let resp = ui.selectable_label(false, label); + // The ✓ and the padding are the menu's drawing: say it as a state. + let checkable = matches!(i, 0 | 1 | 2 | 4 | 6 | 7); + a11y::menu_item(&resp, label.trim_start_matches('✓').trim(), checkable.then(|| label.starts_with('✓'))); + if i == 0 { + widgets::focus_when_opened(ui, mpop, &resp); + } if i == 12 { app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path"); } @@ -328,6 +579,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None }; if let Some(i) = choice { + if widgets::by_keyboard(ui) { + menu_resp.request_focus(); + } let v = &mut app.ui.effects_view; match i { 0 => v.depth = "all".into(), @@ -354,6 +608,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; let mut apply: Option = None; + let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::(cursor_id())), offset: scroll.offset, list_top: list.min.y }; let reg = effectcraft_engine::effects::all(); let keep = |e: &effectcraft_engine::effects::EffectSpec| { depth_keeps(&view.depth, e) @@ -363,17 +618,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Favorites and Recently Used. let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); - if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) { + if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "favorites", "Favorites", 1, 0.0, searching, Some(Icon::Star)) { let mut favs = favs; favs.sort_by_key(|e| e.name); for e in favs { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.favorites", &mut apply); } } let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); - if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) { + if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, &mut nav, "recent", "Recently Used", 1, 0.0, false, None) { for e in recent { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.recent", &mut apply); } } @@ -392,21 +647,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let files: Vec = user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect(); let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty(); - if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { - if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { + if any && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets", "* Animation Presets", 1, 0.0, searching, None) { + if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/Text", "Text", 2, 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row( + app, + ui, + &lp, + list, + &mut y, + &mut nav, + &name, + &format!("effects.preset.text.{id}"), + &query, + Apply::TextPreset(id.clone()), + &mut apply, + ); } } for cat in &categories { - if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) { + if !folder_row(app, ui, &lp, list, &mut y, &mut nav, &format!("presets/{cat}"), cat, 2, 14.0, searching, None) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row( + app, + ui, + &lp, + list, + &mut y, + &mut nav, + name, + &format!("effects.preset.builtin.{id}"), + &query, + Apply::Builtin(id.clone()), + &mut apply, + ); } } - if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { + if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/User", "User Presets", 2, 14.0, searching, None) { for f in files { preset_row( app, @@ -414,6 +693,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &lp, list, &mut y, + &mut nav, &f.name, &format!("effects.preset.file.{}", f.name), &query, @@ -430,7 +710,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect(); all.sort_by_key(|e| e.name.to_lowercase()); for e in all { - effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 1, -16.0, &query, "effects.item", &mut apply); } } else { let mut cats: Vec<&str> = effectcraft_engine::effects::categories(); @@ -442,11 +722,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // The folder draws the category in the interface language; `cat` stays the key (the // open/closed state and the automation id are the registry's English name). - if !folder_row(app, ui, &lp, list, &mut y, cat, &crate::i18n::effects::category(cat), 0.0, searching, None) { + if !folder_row(app, ui, &lp, list, &mut y, &mut nav, cat, &crate::i18n::effects::category(cat), 1, 0.0, searching, None) { continue; } for e in items { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.item", &mut apply); } } } @@ -455,6 +735,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); + if let Some(a) = list_keys(app, &ctx, &nav, list) { + apply = Some(a); + } if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..871c9dfeb 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -20,6 +20,7 @@ use effectcraft_engine::project::render_templates::RenderTemplates; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::Tokens; use crate::widgets::check_label as mark; use crate::{EffectcraftApp, widgets}; @@ -52,6 +53,11 @@ pub fn utc_stamp(unix: u64) -> String { format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60) } +/// A queue item's row: its click area, keyboard focus and accessibility node. +fn row_id(item: u64) -> egui::Id { + egui::Id::new(("rq-row", item)) +} + fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { match s { RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c), @@ -456,14 +462,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { + let render_resp = widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")); + a11y::button(&render_resp, "Render", can_render); + if render_resp.clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); // Notify (sound + notification when the queue finishes). let notify = app.session.project.render_prefs.notify; let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + let notify_resp = widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")); + a11y::check_box(¬ify_resp, tr("Notify"), notify, true); + if notify_resp.clicked() { actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); } p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); @@ -496,8 +506,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }; - p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text); + p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, &bar_text, Tokens::ui(11.5), t.text); app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0)); + a11y::progress(&ui.interact(bar, egui::Id::new("rq-progress"), Sense::hover()), &bar_text, frac); // ---------------------------------------------------------- column header let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H)); @@ -534,6 +545,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if queue.is_empty() { lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint); } + let ctx = ui.ctx().clone(); + // Where each item's row starts in the scrolled content, for the keys. + let mut tops: Vec<(u64, f32)> = Vec::new(); let editing_id = egui::Id::new("rq-edit-output"); let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id)); for (k, it) in queue.iter().enumerate() { @@ -553,11 +567,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { t.row_alt }, ); - let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click()); - if row_resp.clicked() { + let row_resp = ui.interact(row, row_id(it.id), Sense::click()); + tops.push((it.id, row.min.y - list.min.y + scroll.offset)); + if a11y::take_pending_focus(&ctx, "rq", row_resp.id) { + row_resp.request_focus(); + } + if row_resp.clicked() || row_resp.gained_focus() { selected = Some(it.id); app.ui.focused = crate::dock::PanelKind::RenderQueue; } + if row_resp.has_focus() { + lp.rect_stroke(row.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + let comp_item = app.session.project.item(it.comp); + let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); let item_id = it.id; crate::widgets::context_menu(&row_resp, |ui| { if ui.button(tr("Duplicate")).clicked() { @@ -580,7 +603,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&aid("row"), row, &format!("#{n}")); let is_open = open.contains(&it.id); let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0)); - if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() { + let tw_resp = widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t); + a11y::disclosure(&tw_resp, &tr_args(if is_open { "Hide details of {}" } else { "Show details of {}" }, &[&name]), is_open); + if tw_resp.clicked() { if is_open { open.retain(|i| *i != it.id); } else { @@ -589,15 +614,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&aid("twirl"), tw, "Twirl"); let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); - if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + let cb_resp = widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))); + a11y::check_box(&cb_resp, &format!("{}: {name}", tr("Render")), it.render, !rendering); + if cb_resp.clicked() && !rendering { actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); } app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); - let comp_item = app.session.project.item(it.comp); let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint); lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col); lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim); - let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text); let status = if rendering && let Some(pr) = progress.as_ref().filter(|p| p.current == Some(it.id)) { format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }) @@ -610,6 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e); } app.auto.add(&aid("status"), st_rect, &status); + a11y::tree_item(&row_resp, &format!("{n}. {name}, {status}"), is_sel, 1, Some(is_open)); if let Some(s) = it.started { lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim); } else { @@ -641,11 +667,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0)); let did = egui::Id::new(("rq-dd", it.id, sub)); let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() }; - if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering { + let dd_resp = widgets::dropdown(ui, dd, &shown, &t, did); + a11y::combo_box(&dd_resp, label.trim_end_matches(':'), &summary, !rendering); + if dd_resp.clicked() && !rendering { widgets::open_popup(ui, did); } let labels: Vec = menu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) { + if widgets::by_keyboard(ui) { + dd_resp.request_focus(); + } let mut params = menu[i].1.clone(); if let Some(f) = params.get("form").and_then(Value::as_str) { forms.push((it.clone(), f.to_string())); @@ -673,11 +704,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0)); lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim); let lid = egui::Id::new(("rq-log", it.id)); - if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering { + let log_resp = widgets::dropdown(ui, ld, it.log.label(), &t, lid); + a11y::combo_box(&log_resp, tr("Log:").trim_end_matches(':'), it.log.label(), !rendering); + if log_resp.clicked() && !rendering { widgets::open_popup(ui, lid); } let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect(); if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) { + if widgets::by_keyboard(ui) { + log_resp.request_focus(); + } actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()}))); } app.auto.add(&aid("log"), ld, it.log.label()); @@ -688,13 +724,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim); let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0)); let oid = egui::Id::new(("rq-out", it.id)); - if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering { + let out_resp = widgets::dropdown(ui, odd, "", &t, oid); + a11y::combo_box( + &out_resp, + tr("Output To:").trim_end_matches(':'), + &app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()), + !rendering, + ); + if out_resp.clicked() && !rendering { widgets::open_popup(ui, oid); } app.auto.add(&aid("outputTo"), odd, "Output To"); let omenu = output_to_menu(it); let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) { + if widgets::by_keyboard(ui) { + out_resp.request_focus(); + } if omenu[i].1.get("choose").is_some() { match pick_output(app, it) { Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), @@ -712,6 +758,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect)); let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5))); + a11y::text_input(&resp, tr("Output file name template"), &buf); if resp.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() { actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()}))); @@ -724,6 +771,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()); let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click()); + a11y::button(&resp, &tr_args("Output file: {}", &[&shown]), !rendering); let col = if resp.hovered() { t.accent_hover } else { t.hot_text }; lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col); // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a @@ -753,6 +801,69 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => d.remove::<(u64, String)>(editing_id), }); + // Keyboard: Up / Down move between the queued items, Home / End go to the first / last, Right + // and Left open and close the item's settings, Enter and Space do the same (Space only with a + // row focused: otherwise it plays the preview). The focus follows the selection. + let focused = ctx.memory(|m| m.focused()); + let on_row = focused.is_some_and(|f| queue.iter().any(|i| row_id(i.id) == f)); + let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::RenderQueue; + if (on_row || by_mouse) && editing.is_none() && app.dialog.is_none() && !widgets::any_menu_open(&ctx) { + let keys: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect() + }); + let mut moved = None; + for key in keys { + use egui::Key; + let at = selected.and_then(|s| queue.iter().position(|i| i.id == s)); + let Some(last) = queue.len().checked_sub(1) else { break }; + let to = match key { + Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))), + Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))), + Key::Home if on_row => Some(0), + Key::End if on_row => Some(last), + _ => None, + }; + if let Some(item) = to.and_then(|to| queue.get(to)) { + selected = Some(item.id); + moved = Some(item.id); + continue; + } + let Some(item) = selected.filter(|s| queue.iter().any(|i| i.id == *s)) else { continue }; + match key { + Key::ArrowRight if !open.contains(&item) => open.push(item), + Key::ArrowLeft => open.retain(|i| *i != item), + Key::Enter | Key::Space if on_row || key == Key::Enter => { + if open.contains(&item) { + open.retain(|i| *i != item); + } else { + open.push(item); + } + } + _ => {} + } + } + if let Some(item) = moved { + if let Some((_, top)) = tops.iter().find(|(id, _)| *id == item) { + widgets::PanelScroll::reveal(&ctx, egui::Id::new("rq-scroll"), *top, *top + ROW_H, list.height()); + } + app.ui.focused = crate::dock::PanelKind::RenderQueue; + if on_row { + a11y::focus_next_pass(&ctx, "rq", row_id(item)); + } + ctx.request_repaint(); + } + // The arrows are the queue's, not egui's spatial focus moves. + if on_row { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + } + // Delete removes the selected item when the panel has focus. if app.ui.focused == crate::dock::PanelKind::RenderQueue && editing.is_none() @@ -760,7 +871,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(id) = selected && app.dialog.is_none() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() + // (A focused queue row is not typing; any other focused widget may be.) + && (on_row || !ui.ctx().egui_wants_keyboard_input()) { actions.push(("renderQueue.remove", json!({"item": id}))); selected = None; @@ -790,7 +902,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { open.retain(|id| queue.iter().any(|i| i.id == *id)); ui.data_mut(|d| d.insert_temp(state_id, (open, selected))); - let ctx = ui.ctx().clone(); for (it, f) in forms { open_menu_form(app, &it, &f); } diff --git a/crates/ui-egui/src/panels/rq_templates.rs b/crates/ui-egui/src/panels/rq_templates.rs index f03771133..a090a89fe 100644 --- a/crates/ui-egui/src/panels/rq_templates.rs +++ b/crates/ui-egui/src/panels/rq_templates.rs @@ -11,6 +11,7 @@ use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKin use egui::{Color32, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -63,6 +64,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.selectable_value(&mut pick, n.clone(), n); } }); + a11y::combo_box(&r.response, slot.label(), &cur, true); app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur); if pick != cur { actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick}))); @@ -80,6 +82,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.selectable_value(&mut st.selected, n.clone(), n); } }); + a11y::combo_box(&r.response, tr("Settings Name:").trim_end_matches(':'), &st.selected, true); app.auto.add("templates.select", r.response.rect, &st.selected); }); let builtin = RenderTemplates::is_builtin(k, &st.selected) @@ -135,6 +138,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("New from Render Queue item:")); let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text(tr("template name")).desired_width(180.0)); + a11y::text_input(&r, tr("template name"), &st.new_name); app.auto.add("templates.newName", r.rect, &st.new_name); let item = queue.last().map(|i| i.id); let ok = item.is_some() && !st.new_name.trim().is_empty(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..8020dfd3a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -13,6 +13,7 @@ use effectcraft_engine::segment::Task; use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::{ThemeKind, Tokens}; use crate::{Dialog, EffectcraftApp}; @@ -78,6 +79,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.horizontal(|ui| { ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:"))); let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() }; + let shown_name = shown.clone(); let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| { if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() { acts.push(Act::Set(key.into(), json!(""))); @@ -88,6 +90,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } } }); + a11y::combo_box(&r.response, tr("Default Output:").trim_end_matches(':'), &shown_name, true); reg(app, &format!("settings.{key}"), &r.response, "Default Output"); }); if devices.is_empty() { @@ -103,6 +106,8 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let id = format!("settings.{key}"); let tip = (!live).then(|| tr("Stored, but not used by EffectCraft yet")); let mut resp: Option = None; + // What the control is set to, for assistive technology. + let mut value_text = String::new(); match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); @@ -157,6 +162,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value Kind::Choice(opts) => { let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string); let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string(); + value_text = tr(&shown).to_string(); egui::ComboBox::from_id_salt(("settings", key)) .selected_text(tr(&shown)) .width(220.0) @@ -172,6 +178,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value Kind::Text | Kind::Path => { let mut s = v.as_str().unwrap_or("").to_string(); let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 })); + value_text = s.clone(); if r.changed() { acts.push(Act::Set(key.into(), json!(s))); } @@ -189,6 +196,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let mut f = c.map(|v| v as f32 / 255.0); let r = crate::widgets::srgb_color_button(ui, &mut f); let rgb = if r.changed() { srgb_bytes(f) } else { c }; + value_text = hex(rgb); ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -202,6 +210,17 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value }); } } + if let Some(r) = &resp { + // The field's label is drawn beside it, not on it: give the control its name. + let name = tr(label); + match kind { + Kind::Bool => {} + Kind::Choice(_) => a11y::combo_box(r, name, &value_text, true), + Kind::Text | Kind::Path => a11y::text_input(r, name, &value_text), + Kind::Color => a11y::name_value(r, name, &value_text), + _ => a11y::name(r, name), + } + } if let Some(r) = resp { let r = match tip { Some(tip) => r.on_hover_text(tip), @@ -389,6 +408,7 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T } }) .response; + a11y::combo_box(&r, tr("Appearance Mode"), tr(shown), true); reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode"); }); ui.add_space(6.0); @@ -599,12 +619,14 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu let mut f = c.map(|v| v as f32 / 255.0); let r = crate::widgets::srgb_color_button(ui, &mut f); let rgb = if r.changed() { srgb_bytes(f) } else { c }; + a11y::name_value(&r, &tr_args("Label {} color", &[&(i + 1)]), &hex(rgb)); app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); } let mut name = l["name"].as_str().unwrap_or("").to_string(); let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name)); + a11y::text_input(&r, &tr_args("Label {} name", &[&(i + 1)]), &name); app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name"); if r.changed() { acts.push(Act::Set(format!("labels.{i}.name"), json!(name))); @@ -664,9 +686,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.vertical(|ui| { ui.set_width(180.0); egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { - for p in &all { - let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click()); + // One Tab stop for the list (the open page); Up / Down / Home / End move + // between the pages and the focus goes with them. + let ctx = ui.ctx().clone(); + let page_item = |id: &str| egui::Id::new(("settings-page", id.to_string())); + let on_list = all.iter().any(|q| ctx.memory(|m| m.has_focus(page_item(q.id)))); + for (n, p) in all.iter().enumerate() { let sel = page == p.id; + let r = ui.allocate_space(vec2(176.0, 24.0)).1; + let resp = ui.interact(r, page_item(p.id), if sel { egui::Sense::click() } else { egui::Sense::CLICK }); + a11y::list_item(&resp, tr(p.title), sel, n + 1, all.len()); + if a11y::take_pending_focus(&ctx, "settings", resp.id) { + resp.request_focus(); + } if sel { ui.painter().rect_filled(r, 3.0, t.accent); } else if resp.hovered() { @@ -679,11 +711,44 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { Tokens::ui(12.5), if sel { Color32::WHITE } else { t.text }, ); + if resp.has_focus() { + ui.painter().rect_stroke(r.shrink(1.0), 3.0, egui::Stroke::new(2.0, t.text), egui::StrokeKind::Inside); + } app.auto.add(&format!("settings.page.{}", p.id), r, p.title); if resp.clicked() { page = p.id.to_string(); } } + if on_list { + let at = all.iter().position(|q| q.id == page).unwrap_or(0); + let last = all.len().saturating_sub(1); + let mut to = at; + for key in ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect::>() + }) { + to = match key { + egui::Key::ArrowDown => to.saturating_add(1).min(last), + egui::Key::ArrowUp => to.saturating_sub(1), + egui::Key::Home => 0, + egui::Key::End => last, + _ => to, + }; + } + if to != at + && let Some(q) = all.get(to) + { + page = q.id.to_string(); + a11y::focus_next_pass(&ctx, "settings", page_item(q.id)); + } + // The arrows are the list's, not egui's spatial focus moves. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } }); }); ui.separator(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..d4994b7d1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -245,6 +245,10 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)] }; ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col))); + // A button that opens and closes something; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::Button); + crate::a11y::expanded(&resp, open); + focus_outline(ui, &resp, t); resp } @@ -258,6 +262,10 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id if on || resp.hovered() { icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint }); } + // A switch; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::Button); + crate::a11y::checked(&resp, on); + focus_outline(ui, &resp, t); resp } @@ -277,6 +285,8 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens t.icon }; icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), icon, col); + // An icon has no text: the caller names the button. + crate::a11y::role(&resp, egui::accesskit::Role::Button); focus_outline(ui, &resp, t); resp } @@ -318,7 +328,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea } let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); - child.add( + let resp = child.add( egui::TextEdit::singleline(text) .hint_text(hint) .frame(egui::Frame::NONE) @@ -327,7 +337,10 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), - ) + ); + // The hint is the field's visible name (the caller can give it a better one). + crate::a11y::name(&resp, hint); + resp } /// Dropdown-looking control ("Full ▾"). @@ -358,6 +371,10 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: & }; ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + // A drop-down showing `text`; the caller names what it sets. + crate::a11y::role(&resp, egui::accesskit::Role::ComboBox); + crate::a11y::name_value(&resp, "", text); + focus_outline(ui, &resp, t); if let Some(tip) = tip { return resp.on_hover_text(tip.to_string()); } @@ -379,6 +396,8 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token }; ui.painter().rect_filled(rect, rect.height() / 2.0, bg); ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), if primary { Color32::WHITE } else { t.text }); + crate::a11y::role_name(&resp, egui::accesskit::Role::Button, text); + focus_outline(ui, &resp, t); resp } @@ -392,6 +411,10 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> let p = |x: f32, y: f32| pos2(b.min.x + x * b.width(), b.min.y + y * b.height()); ui.painter().line(vec![p(0.2, 0.52), p(0.42, 0.74), p(0.8, 0.28)], Stroke::new(1.6, Color32::WHITE)); } + // A check box; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::CheckBox); + crate::a11y::checked(&resp, on); + focus_outline(ui, &resp, t); resp } @@ -411,7 +434,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0); let travel = (len - thumb_len).max(1.0); let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 }; - let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag()); + // Dragged with the mouse only: a Tab stop that no key could move would be a trap. The panels + // scroll with their own keys (the arrows, Page Up / Page Down) and the wheel. + let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::DRAG); let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed()); let mut s = scroll; if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) { @@ -455,6 +480,22 @@ impl PanelScroll { Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) } } + /// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to + /// `bottom` is in view of a `view` tall list, from the next pass. + pub fn reveal(ctx: &egui::Context, id: egui::Id, top: f32, bottom: f32, view: f32) { + let (offset, max): (f32, f32) = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); + let wanted = if top < offset { + top + } else if bottom > offset + view { + bottom - view + } else { + offset + } + .max(0.0); + // (`max` is last frame's overflow; the next `end` measures it again.) + ctx.data_mut(|d| d.insert_temp(id, (wanted, max.max(wanted)))); + } + /// The rows were `content` points tall: keep the offset, with the scroll bar registered for /// automation as `key`. pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) { @@ -537,6 +578,30 @@ pub fn label_popup( }) } +/// This pass's input has no mouse press or release: whatever just happened (a control activated, +/// a list opened, an entry chosen) came from the keyboard or an assistive technology. +pub fn by_keyboard(ui: &Ui) -> bool { + !ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released()) +} + +/// Put the keyboard on `entry` of the popup list `id` the pass the list opens, so Up / Down and +/// Enter work it and Escape closes it. (The pass count tells a fresh opening from a list that has +/// been open for some passes.) +pub fn focus_when_opened(ui: &Ui, id: egui::Id, entry: &Response) { + let key = id.with("focus-pass"); + let pass = ui.ctx().cumulative_pass_nr(); + let previous = ui.data_mut(|d| { + let previous = d.get_temp::(key); + d.insert_temp(key, pass); + previous + }); + // A list opened with the mouse keeps the keyboard where it was: a widget with the focus + // pauses the application's shortcuts (Space, Ctrl+Z) until the focus is lost. + if previous.is_none_or(|p| p.saturating_add(1) < pass) && by_keyboard(ui) { + entry.request_focus(); + } +} + /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { if !popup_is_open(ui, id) { @@ -546,11 +611,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| { ui.set_min_width(160.0); let mut chosen = None; + let mut first = true; for (i, o) in options.iter().enumerate() { if o == "-" { ui.separator(); - } else if ui.selectable_label(current == Some(i), o).clicked() { - chosen = Some(i); + } else { + let r = ui.selectable_label(current == Some(i), o); + // The keyboard starts on the current entry (the first one, with none current). + if current == Some(i) || (current.is_none() && first) { + focus_when_opened(ui, id, &r); + } + first = false; + if r.clicked() { + chosen = Some(i); + } } } chosen diff --git a/crates/ui-egui/tests/ui_accessibility_audit.rs b/crates/ui-egui/tests/ui_accessibility_audit.rs new file mode 100644 index 000000000..012bf914f --- /dev/null +++ b/crates/ui-egui/tests/ui_accessibility_audit.rs @@ -0,0 +1,377 @@ +//! Accessibility audit (docs/gaps.md gap 25): renders panels headless and walks the AccessKit +//! tree of the frame. Every interactive node (button, check box, text input, combo box, slider, +//! spin button, tree or list item, tab, link, ...) must have a non-empty name, a role that says +//! what it is, and the state that role needs (checked, selected, a value). +//! +//! * The panels that had their pass (`FIXED`: Effects & Presets, Settings, Render Queue with its +//! Templates dialog) must have **zero** findings. +//! * Every other panel is counted and printed (run with `--nocapture`), and checked against +//! [`BASELINE`], a ratchet that can only shrink: fixing a panel lowers its number, and a number +//! must never go up. When a panel's count falls, lower its entry (the test says so). +//! +//! What this does not measure: how a real screen reader speaks the tree (none was run), colour +//! contrast, focus order or keyboard operation (the `ui_*_keyboard.rs` tests cover those), or +//! widgets that are scrolled out of view and not drawn. + +use std::collections::HashSet; + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::{Action, Role, Toggled}; +use egui::{Rect, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::NodeT; +use serde_json::json; + +/// Groups that must be clean. A group is the first part of a scenario name (before the `:`). +const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project"]; + +/// (scenario, findings, of them unnamed controls) per not-yet-fixed scenario, as measured when +/// this test was written (Effect Controls with one Gaussian Blur on the layer, every other panel +/// as it opens). A ratchet: a number may only go down, never up; when a pass over a panel lowers +/// it, lower its entry in the same change, and remove the entry (and add the panel to `FIXED`) +/// when it reaches zero. A scenario that is not listed is held to zero. +const BASELINE: &[(&str, usize, usize)] = &[ + ("EffectControls:default", 25, 15), + ("Composition:default", 16, 14), + ("Layer:default", 4, 3), + ("Timeline:default", 83, 55), + ("Audio:default", 4, 2), + ("Preview:default", 5, 5), + ("Properties:default", 45, 28), + ("Character:default", 13, 9), + ("Paragraph:default", 24, 12), + ("Align:default", 18, 11), + ("Tracker:default", 1, 1), + ("Wiggler:default", 2, 2), + ("Smoother:default", 2, 1), + ("MotionSketch:default", 4, 2), + ("Paint:default", 5, 3), + ("Brushes:default", 16, 9), + ("Flowchart:default", 8, 4), + ("History:default", 10, 5), + ("Markers:default", 1, 1), + ("MaskInterpolation:default", 2, 2), + ("ScriptConsole:default", 5, 3), + ("EssentialGraphics:default", 2, 2), + ("Footage:default", 3, 2), + ("Progress:default", 1, 1), + ("VrCompEditor:default", 13, 7), +]; + +/// How many controls the fixed scenarios must at least show, so that "zero findings" is never +/// the result of looking at nothing. Any scenario not listed needs 2. +const MIN_CONTROLS: &[(&str, usize)] = &[ + ("EffectsPresets:default", 10), + ("EffectsPresets:open", 10), + ("EffectsPresets:search", 5), + ("EffectsPresets:menu", 8), + ("Settings:general", 12), + ("Settings:appearance", 15), + ("Settings:labels", 30), + ("RenderQueue:queued", 8), + ("RenderQueue:expanded", 12), + ("RenderQueue:menu", 20), + ("RenderQueue:templates:renderSettings", 8), + ("RenderQueue:templates:outputModule", 8), +]; + +/// The roles that are controls: anything with one of these (or with a click or focus action) is +/// held to the rules below. +const CONTROL_ROLES: &[Role] = &[ + Role::Button, + Role::CheckBox, + Role::ColorWell, + Role::ComboBox, + Role::Link, + Role::ListBoxOption, + Role::ListItem, + Role::MenuItem, + Role::MenuItemCheckBox, + Role::MenuItemRadio, + Role::MultilineTextInput, + Role::RadioButton, + Role::SearchInput, + Role::Slider, + Role::SpinButton, + Role::Switch, + Role::Tab, + Role::TextInput, + Role::TreeItem, +]; + +#[derive(Debug, Clone)] +struct Finding { + scenario: String, + problem: &'static str, + role: Role, + name: String, + rect: [i32; 4], +} + +impl std::fmt::Display for Finding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}: {:?} {:?} at {:?}", self.scenario, self.problem, self.role, self.name, self.rect) + } +} + +fn app_session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Blue", "color": "#4040c0"})).unwrap(); + s.execute("effect.apply", json!({"effect": "Gaussian Blur"})).ok(); + s +} + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(app_session())); + h.run_steps(4); + h +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(4); +} + +fn panel_rect(h: &Harness<'_, EffectcraftApp>, p: PanelKind) -> Option { + let e = h.state().auto.find(&format!("panel.{}", p.id()))?; + Some(Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) +} + +fn dialog_rect(h: &Harness<'_, EffectcraftApp>, title: &str) -> Option { + h.ctx.memory(|m| m.area_rect(egui::Id::new(("modal", title)))) +} + +/// The ids of every node now, so a later audit can look at what appeared since (a popup). +fn node_ids(h: &Harness<'_, EffectcraftApp>) -> HashSet { + h.root().children_recursive().map(|n| format!("{:?}", n.accesskit_node().id())).collect() +} + +/// Walk the tree and check every control whose centre is inside `scope` (and, when `since` is +/// given, that was not in the tree before). +fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Option<&HashSet>) -> (Vec, usize) { + let mut out = Vec::new(); + let mut controls = 0; + for n in h.root().children_recursive() { + let a = n.accesskit_node(); + if a.is_hidden() { + continue; + } + let Some(b) = a.bounding_box() else { + // No bounds: assistive technology cannot place it. Only a control is a finding (and + // it is one wherever it is: there is no position to scope it by). + if CONTROL_ROLES.contains(&a.role()) { + out.push(Finding { scenario: scenario.to_string(), problem: "no bounds", role: a.role(), name: a.label().unwrap_or_default(), rect: [0; 4] }); + } + continue; + }; + if !scope.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) { + continue; + } + if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) { + continue; + } + let role = a.role(); + let actionable = a.data().supports_action(Action::Click) || a.data().supports_action(Action::Focus); + let control = CONTROL_ROLES.contains(&role); + if !control && !(actionable && matches!(role, Role::Unknown | Role::GenericContainer)) { + continue; + } + let name = a.label().unwrap_or_default(); + controls += 1; + let finding = |problem: &'static str| Finding { + scenario: scenario.to_string(), + problem, + role, + name: name.clone(), + rect: [b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32], + }; + if !control && matches!(role, Role::Unknown | Role::GenericContainer) { + // Something you can click or focus, that no role describes. + out.push(finding("clickable or focusable without a control role")); + } + if name.trim().is_empty() { + out.push(finding("no name")); + } + let state_missing = match role { + Role::CheckBox | Role::RadioButton | Role::Switch | Role::MenuItemCheckBox | Role::MenuItemRadio => { + a.toggled().is_none() || a.toggled() == Some(Toggled::Mixed) + } + Role::Slider | Role::SpinButton => a.numeric_value().is_none(), + Role::ComboBox => a.value().is_none_or(|v| v.trim().is_empty()), + Role::TreeItem | Role::ListItem | Role::ListBoxOption | Role::Tab => a.is_selected().is_none(), + _ => false, + }; + if state_missing { + out.push(finding("no state or value")); + } + } + (out, controls) +} + +fn count_unnamed(f: &[Finding]) -> usize { + f.iter().filter(|f| f.problem == "no name").count() +} + +struct Report { + findings: Vec, + /// (scenario, findings, of them unnamed, controls looked at) + rows: Vec<(String, usize, usize, usize)>, +} + +impl Report { + fn add(&mut self, scenario: &str, (f, controls): (Vec, usize)) { + self.rows.push((scenario.to_string(), f.len(), count_unnamed(&f), controls)); + self.findings.extend(f); + } +} + +fn group(scenario: &str) -> &str { + scenario.split(':').next().unwrap_or(scenario) +} + +fn collect() -> Report { + let mut r = Report { findings: vec![], rows: vec![] }; + + // ---- Effects & Presets: closed, everything open, searching, the contents menu. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::EffectsPresets).expect("Effects & Presets is drawn"); + r.add("EffectsPresets:default", audit(&h, "EffectsPresets:default", rect, None)); + { + let s = h.state_mut(); + s.ui.effects_favorites.insert("ec.blur.gaussian".into()); + s.ui.effects_open.insert("favorites".into()); + s.ui.effects_open.insert("presets".into()); + s.ui.effects_open.insert("presets/Text".into()); + s.ui.effects_open.insert("Blur & Sharpen".into()); + } + h.run_steps(4); + r.add("EffectsPresets:open", audit(&h, "EffectsPresets:open", rect, None)); + h.state_mut().ui.effects_search = "blur".into(); + h.run_steps(4); + r.add("EffectsPresets:search", audit(&h, "EffectsPresets:search", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-contents-menu").with("open"), true)); + h.run_steps(4); + let screen = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(1600.0, 1000.0)); + r.add("EffectsPresets:menu", audit(&h, "EffectsPresets:menu", screen, Some(&before))); + + // ---- Settings: every page. + let mut h = harness(); + let before = node_ids(&h); + for page in effectcraft_engine::prefs::pages() { + invoke(&mut h, "app.settings", json!({"page": page.id})); + let name = format!("Settings:{}", page.id); + let rect = dialog_rect(&h, "Settings").expect("the Settings dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + + // ---- Render Queue: empty, queued and expanded, a menu open, the Templates dialogs. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::RenderQueue).expect("the Render Queue is drawn"); + r.add("RenderQueue:empty", audit(&h, "RenderQueue:empty", rect, None)); + let comp = h.state().session.active_comp_id().expect("a comp").0; + let mut items = vec![]; + for _ in 0..2 { + let out = h.state_mut().session.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + items.push(out["item"].as_u64().or_else(|| out["items"][0].as_u64()).unwrap_or(0)); + } + h.run_steps(4); + r.add("RenderQueue:queued", audit(&h, "RenderQueue:queued", rect, None)); + let first = h.state().session.project.render_queue.first().map(|i| i.id).expect("a queue item"); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (vec![first], Some(first)))); + h.run_steps(4); + r.add("RenderQueue:expanded", audit(&h, "RenderQueue:expanded", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("rq-dd", first, 0)).with("open"), true)); + h.run_steps(4); + r.add("RenderQueue:menu", audit(&h, "RenderQueue:menu", screen, Some(&before))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("rq-dd", first, 0)).with("open"), false)); + let before = node_ids(&h); + for (kind, title) in [("renderSettings", "Render Settings Templates"), ("outputModule", "Output Module Templates")] { + invoke(&mut h, "app.templates", json!({"kind": kind})); + let name = format!("RenderQueue:templates:{kind}"); + let rect = dialog_rect(&h, title).expect("the Templates dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + let _ = items; + + // ---- Everything else, informational: each panel on its own. + let mut h = harness(); + for p in PanelKind::ALL { + if matches!(p, PanelKind::EffectsPresets | PanelKind::RenderQueue) { + continue; + } + h.state_mut().show_panel(p); + h.run_steps(3); + let name = format!("{}:default", p.id()); + let Some(rect) = panel_rect(&h, p) else { + r.rows.push((format!("{name} (not drawn)"), 0, 0, 0)); + continue; + }; + r.add(&name, audit(&h, &name, rect, None)); + } + r +} + +#[test] +fn fixed_panels_have_no_unnamed_or_roleless_controls_and_the_others_do_not_get_worse() { + let report = collect(); + println!("\nAccessibility audit: findings per scenario (unnamed in brackets)"); + let mut by_group: Vec<(String, usize, usize, usize)> = vec![]; + for (name, n, unnamed, controls) in &report.rows { + println!(" {name:<44} {n:>4} findings ({unnamed} unnamed) of {controls} controls"); + let g = group(name).to_string(); + match by_group.iter_mut().find(|(k, ..)| *k == g) { + Some(e) => { + e.1 += n; + e.2 += unnamed; + e.3 += controls; + } + None => by_group.push((g, *n, *unnamed, *controls)), + } + } + println!("\nBy panel (findings, unnamed controls, controls looked at):"); + for (g, n, unnamed, controls) in &by_group { + let state = if FIXED.contains(&g.as_str()) { "fixed: must be 0" } else { "not yet audited" }; + println!(" {g:<28} {n:>4} findings, {unnamed:>3} unnamed, {controls:>4} controls {state}"); + } + let total_unnamed: usize = by_group.iter().map(|(_, _, u, _)| u).sum(); + println!(" all panels: {total_unnamed} unnamed controls\n"); + + let mut failures = vec![]; + for f in &report.findings { + if FIXED.contains(&group(&f.scenario)) { + failures.push(f.to_string()); + } + } + assert!(failures.is_empty(), "{} accessibility findings in the fixed panels:\n{}", failures.len(), failures.join("\n")); + + // A clean panel must not be clean because nothing was looked at. + for (name, _, _, controls) in &report.rows { + if FIXED.contains(&group(name)) { + let min = MIN_CONTROLS.iter().find(|(k, _)| k == name).map_or(2, |(_, v)| *v); + assert!(*controls >= min, "{name}: only {controls} controls found in the tree (expected {min}); the scenario is not showing the panel"); + } + } + + for (name, n, unnamed, _) in &report.rows { + if FIXED.contains(&group(name)) { + continue; + } + let (allowed, allowed_unnamed) = BASELINE.iter().find(|(k, ..)| k == name).map_or((0, 0), |(_, v, u)| (*v, *u)); + assert!(*n <= allowed, "{name}: {n} findings, the baseline allows {allowed}; a panel must not get worse"); + assert!(*unnamed <= allowed_unnamed, "{name}: {unnamed} unnamed controls, the baseline allows {allowed_unnamed}; a panel must not get worse"); + if *n < allowed || *unnamed < allowed_unnamed { + println!("note: {name} improved to {n} findings ({unnamed} unnamed); lower its BASELINE entry from {allowed} ({allowed_unnamed})"); + } + } +} diff --git a/crates/ui-egui/tests/ui_contrast.rs b/crates/ui-egui/tests/ui_contrast.rs new file mode 100644 index 000000000..34352b5e5 --- /dev/null +++ b/crates/ui-egui/tests/ui_contrast.rs @@ -0,0 +1,92 @@ +//! Colour contrast of the theme tokens (docs/gaps.md gap 25), separate from the AccessKit audit: +//! the WCAG 2 contrast ratio of the text, accent and focus colours against the backgrounds they +//! are drawn on, for every theme. Text needs 4.5:1, the focus ring and control borders 3:1. +//! +//! Pairs that fall short today are listed in [`KNOWN_BELOW`] with the ratio measured when this was +//! written. The list is a ratchet: a pair must not get worse, and a pair that reaches its target +//! must leave the list. This checks the tokens, not what a panel really draws with them (text on +//! a label colour, an image, a disabled control) and not colour-blindness. + +use effectcraft_ui_egui::theme::{ThemeKind, Tokens}; +use egui::Color32; + +/// WCAG 2 relative luminance of an sRGB colour. +fn luminance(c: Color32) -> f64 { + let lin = |v: u8| { + let v = f64::from(v) / 255.0; + if v <= 0.039_28 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) +} + +/// WCAG 2 contrast ratio of two opaque colours. +fn ratio(a: Color32, b: Color32) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +/// (name, foreground, background, minimum ratio) +fn pairs(t: &Tokens) -> Vec<(&'static str, Color32, Color32, f64)> { + vec![ + ("text on panel", t.text, t.panel_bg, 4.5), + ("text on field", t.text, t.field_bg, 4.5), + ("text on selected row", t.text, t.row_selected, 4.5), + ("text on row", t.text, t.row, 4.5), + ("dim text on panel", t.text_dim, t.panel_bg, 4.5), + ("faint text on panel", t.text_faint, t.panel_bg, 4.5), + ("active tab text on panel", t.tab_text_active, t.panel_bg, 4.5), + ("tab text on header", t.tab_text, t.header_bg, 4.5), + ("hot text on panel", t.hot_text, t.panel_bg, 4.5), + ("white on accent (primary button)", Color32::WHITE, t.accent, 4.5), + ("danger text on panel", t.danger, t.panel_bg, 4.5), + ("warning text on panel", t.warning, t.panel_bg, 4.5), + ("accent (focus ring) on panel", t.accent, t.panel_bg, 3.0), + ("focus colour on panel", t.focus, t.panel_bg, 3.0), + ("field border on panel", t.field_border, t.panel_bg, 3.0), + ("icon on panel", t.icon, t.panel_bg, 3.0), + ] +} + +/// (theme, pair, ratio measured when written). Ratchet: never below the number; remove the entry +/// when the pair reaches its target. +const KNOWN_BELOW: &[(&str, &str, f64)] = &[ + ("dark", "white on accent (primary button)", 3.46), + ("dark", "danger text on panel", 3.90), + ("dark", "field border on panel", 1.38), + ("darker", "white on accent (primary button)", 3.46), + ("darker", "danger text on panel", 4.37), + ("darker", "field border on panel", 1.55), + ("light", "faint text on panel", 4.46), + ("light", "white on accent (primary button)", 3.46), + ("light", "danger text on panel", 3.23), + ("light", "warning text on panel", 1.85), + ("light", "accent (focus ring) on panel", 2.78), + ("light", "focus colour on panel", 2.78), + ("light", "field border on panel", 1.86), +]; + +#[test] +fn print_and_hold_the_theme_contrast() { + let mut failures = vec![]; + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + for (name, fg, bg, min) in pairs(&t) { + let r = ratio(fg, bg); + let known = KNOWN_BELOW.iter().find(|(k, n, _)| *k == kind.name() && *n == name); + println!("{:<7} {name:<36} {r:>5.2}:1 (needs {min})", kind.name()); + match known { + Some((_, _, floor)) => { + if r + 0.005 < *floor { + failures.push(format!("{} / {name}: {r:.2}:1 is worse than the known {floor:.2}:1", kind.name())); + } + if r >= min { + failures.push(format!("{} / {name}: {r:.2}:1 now reaches {min}:1; remove it from KNOWN_BELOW", kind.name())); + } + } + None if r < min => failures.push(format!("{} / {name}: {r:.2}:1 is below {min}:1", kind.name())), + None => {} + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/ui-egui/tests/ui_effects_presets_keyboard.rs b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs new file mode 100644 index 000000000..d6f1378d1 --- /dev/null +++ b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs @@ -0,0 +1,271 @@ +//! Effects & Presets keyboard operation and accessible names (docs/gaps.md gap 25): the list is one +//! Tab stop; the arrow keys, Home / End, Right / Left, Enter and Space work it; the focus follows +//! the cursor; rows are tree items with a name, a selected state, a depth and (folders) an open +//! state; the favourite star is a named button. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +/// The role and name of the node that has the keyboard focus. +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn selected_rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let panel = h.state().auto.find("panel.EffectsPresets").expect("the panel is drawn").rect; + let panel = egui::Rect::from_min_size(pos2(panel[0], panel[1]), egui::vec2(panel[2], panel[3])); + h.root() + .children_recursive() + .filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().is_selected() == Some(true)) + .filter(|n| n.accesskit_node().bounding_box().is_some_and(|b| panel.contains(pos2(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)))) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .collect() +} + +/// Put the keyboard on the first row the way Tab does: from the search field, the menu button, +/// then the list. +fn tab_into_list(h: &mut Harness<'_, EffectcraftApp>) { + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + key(h, Key::Tab, Modifiers::NONE); + assert_eq!(focused(h).map(|f| f.1), Some("Effects & Presets options".to_string()), "Tab leaves the search field for the options button"); + key(h, Key::Tab, Modifiers::NONE); +} + +#[test] +fn tab_reaches_the_list_once_and_leaves_it() { + let mut h = harness(); + tab_into_list(&mut h); + let (role, name) = focused(&h).expect("a row has the focus"); + assert_eq!(role, Role::TreeItem); + assert!(!name.is_empty()); + // The first row is a folder: no star. The next Tab leaves the list instead of walking the + // hundreds of effects. + key(&mut h, Key::Tab, Modifiers::NONE); + let after = focused(&h); + assert!(after.as_ref().is_none_or(|(r, _)| *r != Role::TreeItem), "the second Tab is past the list: {after:?}"); +} + +#[test] +fn arrows_home_end_move_the_cursor_and_the_focus() { + let mut h = harness(); + tab_into_list(&mut h); + let first = focused(&h).unwrap().1; + assert_eq!(selected_rows(&h), Vec::::new(), "Tab alone selects nothing"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).unwrap().1; + assert_ne!(first, second, "Down Arrow moves to the next row"); + assert_eq!(selected_rows(&h), vec![second.clone()], "the row the cursor is on is selected"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first); + key(&mut h, Key::End, Modifiers::NONE); + let last = focused(&h).unwrap().1; + assert_ne!(last, first, "End goes to the last row"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, last, "Down Arrow stays on the last row"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first, "Home goes to the first row"); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_ne!(focused(&h).unwrap().1, first, "Page Down moves a page"); + // The cursor row is scrolled into view: its node is on screen. + let name = focused(&h).unwrap().1; + let r = h.get_by_label(&name).rect(); + assert!(r.min.y >= 0.0 && r.max.y <= 1000.0, "the focused row is on screen: {r:?}"); +} + +#[test] +fn right_and_left_open_and_close_folders_and_step_through_the_tree() { + let mut h = harness(); + h.state_mut().ui.effects_search.clear(); + tab_into_list(&mut h); + // Walk to a category folder (not the presets folder). + let mut guard = 0; + loop { + let n = focused(&h).unwrap().1; + if n == "Blur & Sharpen" { + break; + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40, "no Blur & Sharpen folder reached"); + } + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(folder.accesskit_node().data().level(), Some(1)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Right Arrow opens the folder"); + assert_eq!(h.get_by_label("Blur & Sharpen").accesskit_node().data().is_expanded(), Some(true)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + let child = focused(&h).unwrap(); + assert_eq!(child.0, Role::TreeItem); + assert_ne!(child.1, "Blur & Sharpen", "Right Arrow on an open folder steps into it"); + assert_eq!(h.get_by_label(&child.1).accesskit_node().data().level(), Some(2)); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, "Blur & Sharpen", "Left Arrow on an effect steps out to its folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Left Arrow closes the folder"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Enter opens a closed folder"); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Space closes it again"); +} + +#[test] +fn enter_and_space_apply_an_effect_to_the_selected_layer() { + for apply_key in [Key::Enter, Key::Space] { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + let mut guard = 0; + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + assert!(h.state().session.state.last_effect.is_none()); + key(&mut h, apply_key, Modifiers::NONE); + let applied = h.state().session.state.last_effect.clone(); + assert!(applied.is_some(), "{apply_key:?} on `{effect}` applies it to the selected layer"); + let pb = &h.state().playback; + assert!(!pb.playing && !pb.caching && pb.plan.is_none(), "{apply_key:?} on a focused row is the row's, it does not start a preview"); + } +} + +#[test] +fn the_star_is_a_named_button_on_the_cursor_row_and_enter_toggles_it() { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, name) = focused(&h).expect("the star has the focus"); + assert_eq!(role, Role::Button); + assert_eq!(name, format!("Add to favorites: {effect}")); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().ui.effects_favorites.len(), 1, "Enter on the star adds the favourite"); + let (_, name) = focused(&h).expect("the star keeps the focus"); + assert_eq!(name, format!("Remove from favorites: {effect}")); +} + +#[test] +fn the_search_field_keeps_its_arrows_and_the_layer_does_not_nudge() { + let mut h = harness(); + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + h.get_by_label("Search effects and presets").type_text("blur"); + h.run_steps(3); + assert_eq!(h.state().ui.effects_search, "blur"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert!(selected_rows(&h).is_empty(), "arrows in the search field belong to the field"); + // A click on a row, then the arrows: the list moves, the selected layer stays where it is. + let layer_before = layer_position(&h); + h.state_mut().ui.effects_search.clear(); + h.run_steps(3); + click(&mut h, "effects.category.presets"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selected_rows(&h).len(), 1, "the arrows work after a click, without keyboard focus on a row"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(layer_position(&h), layer_before, "the arrow keys did not nudge the selected layer"); +} + +fn layer_position(h: &Harness<'_, EffectcraftApp>) -> String { + let s = &h.state().session; + let comp = s.active_comp().expect("a comp"); + let layer = comp.layers.first().expect("a layer"); + format!("{:?}", layer.transform()) +} + +#[test] +fn rows_and_controls_have_names_roles_and_states() { + let mut h = harness(); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.state_mut().ui.effects_favorites.insert("ec.blur.gaussian".into()); + h.run_steps(4); + assert_eq!(h.get_by_label("Search effects and presets").accesskit_node().role(), Role::TextInput); + let menu = h.get_by_label("Effects & Presets options"); + assert_eq!(menu.accesskit_node().role(), Role::Button); + assert_eq!(menu.accesskit_node().data().is_expanded(), Some(false), "the options button says its menu is closed"); + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().role(), Role::TreeItem); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(folder.accesskit_node().is_selected(), Some(false)); + let favorites = h.get_by_label("Favorites"); + assert_eq!(favorites.accesskit_node().data().is_expanded(), Some(false)); + // Every effect row in an open category is a leaf at depth 2. + let leaves: Vec<_> = + h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().data().level() == Some(2)).collect(); + assert!(!leaves.is_empty()); + assert!(leaves.iter().all(|n| n.accesskit_node().data().is_expanded().is_none()), "effects are leaves: they have no open state"); + assert!(h.query_by_label_contains("Remove from favorites: ").is_none(), "a favourite hidden in a closed Favorites folder draws no rows"); + assert!(h.query_all_by_label_contains("Add to favorites: ").count() > 1, "the effects' stars are named"); +} + +/// A pick from the options menu with the mouse leaves no widget with the keyboard focus, and Enter +/// after a click in the panel is the panel's: it opens or applies the row and does not start the +/// layer rename the Timeline binds to Enter. +#[test] +fn mouse_picks_leave_no_focus_and_enter_after_a_click_is_the_panels() { + let mut h = harness(); + click(&mut h, "effects.menu"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening the menu with the mouse takes no keyboard focus"); + h.get_by_label_contains("Alphabetical").click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + assert!(h.state().ui.effects_view.alphabetical); + h.state_mut().ui.effects_view.alphabetical = false; + h.run_steps(3); + click(&mut h, "effects.category.presets"); + let open = h.state().ui.effects_open.contains("presets"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().ui.effects_open.contains("presets"), open, "Enter toggles the clicked folder"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_render_queue_keyboard.rs b/crates/ui-egui/tests/ui_render_queue_keyboard.rs new file mode 100644 index 000000000..3f954b7b6 --- /dev/null +++ b/crates/ui-egui/tests/ui_render_queue_keyboard.rs @@ -0,0 +1,226 @@ +//! Render Queue keyboard operation and accessible names (docs/gaps.md gap 25): queue items are +//! tree items (named by number, comp and status; selected; open or closed); the arrow keys, Home / +//! End, Right / Left, Enter and Delete work them; the buttons, the check boxes, the twirls and the +//! drop-downs have names and states; a drop-down opens from the keyboard and closes with the focus +//! back on it; the Templates dialog is named. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + for name in ["Alpha", "Beta", "Gamma"] { + let comp = s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn queue_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.project.render_queue.len() +} + +fn row_name(h: &Harness<'_, EffectcraftApp>, n: usize) -> String { + h.query_all_by_role(Role::TreeItem).map(|r| r.accesskit_node().label().unwrap_or_default()).find(|l| l.starts_with(&format!("{n}. "))).unwrap_or_default() +} + +#[test] +fn rows_are_named_tree_items_with_selected_and_open_state() { + let mut h = harness(); + let first = row_name(&h, 1); + assert!(first.starts_with("1. Alpha, "), "number, comp and status: {first}"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().is_selected(), Some(false)); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(node.accesskit_node().data().level(), Some(1)); + click(&mut h, "renderQueue.item.1.twirl"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(true), "the open item says so"); + let twirl = h.get_by_label("Hide details of Alpha"); + assert_eq!(twirl.accesskit_node().role(), Role::Button); + assert_eq!(twirl.accesskit_node().data().is_expanded(), Some(true)); + // The settings drop-downs of the open item: what they set and what they are set to. + let settings = h.get_by_role_and_label(Role::ComboBox, "Render Settings"); + assert!(settings.accesskit_node().value().is_some_and(|v| !v.is_empty())); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output Module").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Log").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output To").is_some()); + assert!(h.query_by_role_and_label(Role::Button, "Render").is_some()); + let notify = h.get_by_role_and_label(Role::CheckBox, "Notify"); + assert!(notify.accesskit_node().toggled().is_some()); + let render = h.get_by_role_and_label(Role::CheckBox, "Render: Alpha"); + assert_eq!(render.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_role(Role::ProgressIndicator).accesskit_node().data().numeric_value(), Some(0.0)); +} + +#[test] +fn arrows_home_end_right_left_enter_and_delete_work_the_rows() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + let selected = |h: &Harness<'_, EffectcraftApp>| { + h.query_all_by_role(Role::TreeItem) + .filter(|n| n.accesskit_node().is_selected() == Some(true)) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) + .collect::>() + }; + assert_eq!(selected(&h), vec![row_name(&h, 1)], "a focused row is selected"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 2)), "Down Arrow moves to the next item and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 2)).accesskit_node().is_selected(), Some(true)); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)), "Down Arrow stays on the last item"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Right Arrow opens the item"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Left Arrow closes it"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter toggles it"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Space toggles it back"); + assert!(!h.state().playback.playing && !h.state().playback.caching && h.state().playback.plan.is_none(), "Space on a row does not start a preview"); + // Delete removes the selected item, as it does with the mouse's selection. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(queue_len(&h), 3); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(queue_len(&h), 2, "Delete removes the selected item"); +} + +#[test] +fn tab_reaches_every_control_of_a_row() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + let mut seen = vec![focused(&h).unwrap()]; + for _ in 0..40 { + key(&mut h, Key::Tab, Modifiers::NONE); + match focused(&h) { + Some(f) if !seen.contains(&f) => seen.push(f), + _ => {} + } + } + let names: Vec<&str> = seen.iter().map(|s| s.1.as_str()).collect(); + for want in ["Show details of Alpha", "Render: Alpha", "Show details of Beta", "Render: Beta"] { + assert!(names.contains(&want), "Tab reaches `{want}`; reached {names:?}"); + } +} + +#[test] +fn a_drop_down_opens_from_the_keyboard_and_gives_the_focus_back() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + h.get_by_role_and_label(Role::ComboBox, "Render Settings").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + let (role, first_entry) = focused(&h).expect("the first entry has the focus"); + assert_ne!(role, Role::ComboBox, "the keyboard is in the menu: {first_entry}"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).expect("Down Arrow moves to the next entry").1; + assert_ne!(second, first_entry); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(first_entry), "Up Arrow moves back"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.0), Some(Role::ComboBox), "choosing an entry gives the focus back to the drop-down"); +} + +#[test] +fn the_check_box_and_the_buttons_work_from_the_keyboard() { + let mut h = harness(); + let before = h.state().session.project.render_queue.first().map(|i| i.render); + h.get_by_role_and_label(Role::CheckBox, "Render: Alpha").focus(); + h.run_steps(3); + key(&mut h, Key::Space, Modifiers::NONE); + assert_ne!(h.state().session.project.render_queue.first().map(|i| i.render), before, "Space toggles the Render check box"); + let notify = h.state().session.project.render_prefs.notify; + h.get_by_role_and_label(Role::CheckBox, "Notify").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().session.project.render_prefs.notify, notify, "Enter toggles Notify"); +} + +#[test] +fn the_templates_dialog_is_named() { + let mut h = harness(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.templates", json!({"kind": "renderSettings"})).unwrap(); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::ComboBox, "Settings Name").is_some()); + assert!(h.query_by_role_and_label(Role::TextInput, "template name").is_some()); + for name in ["Duplicate", "Delete", " OK "] { + assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().role(), Role::Button, "{name}"); + } + let defaults = h.query_all_by_role(Role::ComboBox).count(); + assert!(defaults >= 4, "the default slots are drop-downs: {defaults}"); +} + +/// A pick with the mouse leaves no widget with the keyboard focus: egui pauses the application's +/// shortcuts (Space, Ctrl+Z) while one has it (#776's rule for the Project panel). +#[test] +fn a_pick_with_the_mouse_leaves_no_keyboard_focus() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + click(&mut h, "renderQueue.item.1.renderSettings"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus"); + let template = h.state().session.project.render_templates.render_settings_list()[0].name.clone(); + h.get_by_label_contains(&template).click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + // The Effects & Presets options menu the same. +} + +/// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to +/// the layer rename the Timeline binds to it. +#[test] +fn enter_after_a_click_opens_the_item_and_does_not_rename_a_layer() { + let mut h = harness(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + h.run_steps(3); + click(&mut h, "renderQueue.item.1.row"); + assert_eq!(h.state().ui.focused, PanelKind::RenderQueue); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter opens the clicked item"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_settings_keyboard.rs b/crates/ui-egui/tests/ui_settings_keyboard.rs new file mode 100644 index 000000000..e74ccfd94 --- /dev/null +++ b/crates/ui-egui/tests/ui_settings_keyboard.rs @@ -0,0 +1,113 @@ +//! Settings keyboard operation and accessible names (docs/gaps.md gap 25): the page list is one +//! Tab stop worked with the arrow keys, Home and End; every control on a page has the name its +//! label shows, with its value; Escape still cancels. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::accesskit::{Role, Toggled}; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(page: &str) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": page})).unwrap(); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn titles() -> Vec<&'static str> { + effectcraft_engine::prefs::pages().iter().map(|p| p.title).collect() +} + +#[test] +fn the_page_list_is_one_tab_stop_worked_with_the_arrows() { + let mut h = harness("general"); + let titles = titles(); + let n = titles.len(); + // Every page is a list item: its place in the list, and the open page selected. + for (i, title) in titles.iter().enumerate() { + let item = h.get_by_role_and_label(Role::ListItem, title); + assert_eq!(item.accesskit_node().role(), Role::ListItem, "{title}"); + assert_eq!(item.accesskit_node().data().position_in_set(), Some(i + 1)); + assert_eq!(item.accesskit_node().data().size_of_set(), Some(n)); + assert_eq!(item.accesskit_node().is_selected(), Some(i == 0), "{title}"); + } + h.get_by_role_and_label(Role::ListItem, titles[0]).focus(); + h.run_steps(3); + assert_eq!(focused(&h), Some((Role::ListItem, titles[0].to_string()))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h), Some((Role::ListItem, titles[1].to_string())), "Down Arrow opens the next page and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[1]).accesskit_node().is_selected(), Some(true)); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[0]).accesskit_node().is_selected(), Some(false)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string())); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Up Arrow stops at the first page"); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[n - 1].to_string()), "End goes to the last page"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Home goes to the first page"); + // Only the open page is a Tab stop: Tab leaves the list for the page's first control. + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, _) = focused(&h).expect("Tab moves the focus on"); + assert_ne!(role, Role::ListItem, "Tab leaves the page list"); +} + +#[test] +fn controls_have_the_name_their_label_shows_and_their_value() { + let h = harness("general"); + let undo = h.get_by_label("Levels of Undo"); + assert_eq!(undo.accesskit_node().role(), Role::SpinButton); + assert!(undo.accesskit_node().numeric_value().is_some(), "a spin button carries its number"); + let language = h.get_by_label("Language"); + assert_eq!(language.accesskit_node().role(), Role::ComboBox); + assert!(language.accesskit_node().value().is_some_and(|v| !v.is_empty()), "a drop-down carries what it is set to"); + let tips = h.get_by_label("Show Tool Tips"); + assert_eq!(tips.accesskit_node().role(), Role::CheckBox); + assert!(matches!(tips.accesskit_node().toggled(), Some(Toggled::True | Toggled::False))); + for name in [" OK ", "Cancel", "Previous", "Next"] { + assert_eq!(h.get_by_label(name).accesskit_node().role(), Role::Button, "{name}"); + } + // Previous is disabled on the first page. + assert!(h.get_by_label("Previous").accesskit_node().is_disabled()); +} + +#[test] +fn text_fields_colour_buttons_and_theme_cards_are_named() { + let h = harness("labels"); + let name = h.get_by_label("Label 1 name"); + assert_eq!(name.accesskit_node().role(), Role::TextInput); + assert!(name.accesskit_node().value().is_some_and(|v| !v.is_empty()), "the field carries its text"); + let color = h.get_by_label("Label 1 color"); + assert_eq!(color.accesskit_node().role(), Role::ColorWell); + assert!(color.accesskit_node().value().is_some_and(|v| v.starts_with('#')), "a colour carries its hex value"); + let h = harness("appearance"); + assert_eq!(h.get_by_label("Appearance Mode").accesskit_node().role(), Role::ComboBox); + assert!(h.query_all_by_role(Role::RadioButton).count() >= 2, "the themes are radio buttons"); +} + +#[test] +fn escape_still_cancels_with_the_keyboard_on_the_page_list() { + let mut h = harness("general"); + let first = titles()[0]; + h.get_by_role_and_label(Role::ListItem, first).focus(); + h.run_steps(3); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none(), "Escape closes the dialog"); +} diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..9f4dd854c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest. | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page) and the Render Queue with its Templates dialog are at zero and held there, with named, stateful nodes and arrow-key, Home / End, Enter and Space operation of their lists (#1335). The other 30 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each). `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 12–25 h (first slice, about 3 h, done) | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -211,7 +211,7 @@ The most important missing piece: it turns every other estimate here into a meas Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed: -Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work +Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); accessibility is gap 25 ### G3. Stability and a green main @@ -322,6 +322,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gap 25: an AccessKit audit test (per-panel counts, a ratchet for the unfixed panels) and a theme contrast test; Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668da..791950879 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier change: closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -30,20 +30,21 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304); scrubbing lag (#329, closed by #784) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | +| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages) and Render Queue with Templates are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an -accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After +accessibility pass 12–25 h (the first slice, three panels and the audit, is done). Checking feel against After Effects needs the owner's Mac with After Effects running; most of the rest parallelises. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Accessibility row: AccessKit audit and contrast tests, Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 32296d6f653f3efbaf99df06f17f01495e61be07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 0046/1386] #1284: Cmd+W closes the Render Queue while it has focus With the Render Queue active, Cmd+W ran comp.close on the active composition, so the composition next to the queue closed and the queue stayed open. Close now follows panel focus, as Clear does: the queue closes and the composition stays open. Covered by menus::tests::cmd_w_in_the_render_queue_closes_the_queue_not_the_comp. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/ui-egui/src/menus.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b4977..eb89e6ab0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -291,6 +291,12 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and + // leaves the active composition open (it used to close the composition next to it). + if id == "file.close" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue { + app.close_panel(PanelKind::RenderQueue); + return Ok(Value::Null); + } // Clear follows panel focus, including the Edit menu and keyboard shortcut. if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project { // Through `invoke`, so deleting items that compositions use still asks first (M3.16). @@ -1986,6 +1992,21 @@ mod tests { } } + /// Cmd+W with the Render Queue focused closes the queue; the composition next to it stays + /// open (it used to be closed, and the queue stayed). + #[test] + fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let a = app.session.execute("comp.new", json!({"name": "A", "duration": 4})).unwrap()["comp"].as_u64(); + app.show_panel(PanelKind::RenderQueue); + assert_eq!(app.ui.focused, PanelKind::RenderQueue); + invoke(&mut app, &ctx, "file.close", json!({})).unwrap(); + assert!(!app.ui.dock.contains(PanelKind::RenderQueue)); + assert_eq!(app.session.state.active_comp.map(|c| c.0), a); + assert_eq!(app.session.state.open_comps.len(), 1); + } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores /// it, as in After Effects. #[test] From a7cad22c13a0f7e05708c2279ce88185e3afd7de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 0047/1386] #1284: right-clicking a Graph Editor keyframe opens its key menu The Graph Editor had no menu for keyframes. Right-click now opens the same key menu as the Timeline, for the commands that apply from the graph: Edit Value, Copy and Paste, selecting equal or neighbouring keys, Hold, Rove Across Time (spatial properties only), Easy Ease, interpolation and Delete. The items run the existing keys.* commands, so undo and the automation ids are unchanged. A right-click on an unselected key selects it first, as a click does. Not covered by an automated test; checked by compiling and by reading the menu against the Timeline's. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/ui-egui/src/panels/graph.rs | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 2dbb98268..41e2c5263 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -453,7 +453,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } else if kresp.clicked() && mods.shift { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true}))); - } else if (kresp.clicked() || kresp.drag_started()) && !k.sel { + } else if (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel { actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift}))); } // A drag moves every selected key, by how far the pointer went from where the button @@ -485,6 +485,48 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); actions.push(("__endMerge".into(), json!({}))); } + // Right-click: the Timeline's key menu, for the commands that apply from the Graph Editor. + kresp.context_menu(|ui| { + let editable_value = matches!(key.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)); + for (lbl, cmd, params) in [ + ("Edit Value…", "keys.set", kjson.clone()), + ("-", "", json!({})), + ("Copy", "keys.copy", json!({})), + ("Paste", "keys.paste", json!({})), + ("-", "", json!({})), + ("Select Equal Keyframes", "keys.selectEqual", json!({})), + ("Select Previous Keyframes", "keys.selectPrevious", json!({})), + ("Select Following Keyframes", "keys.selectFollowing", json!({})), + ("-", "", json!({})), + ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Rove Across Time", "keys.interpolation", json!({"roving": !key.roving})), + ("-", "", json!({})), + ("Easy Ease", "keys.easyEase", json!({})), + ("Easy Ease In", "keys.easyEaseIn", json!({})), + ("Easy Ease Out", "keys.easyEaseOut", json!({})), + ("-", "", json!({})), + ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), + ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), + ("-", "", json!({})), + ("Delete", "keys.delete", json!({})), + ] { + if lbl == "-" { + ui.separator(); + continue; + } + if lbl == "Rove Across Time" && !c.prop.spatial { + continue; + } + if lbl == "Edit Value…" && !editable_value { + continue; + } + if ui.button(lbl).clicked() { + actions.push((cmd.into(), params)); + ui.close(); + } + } + }); } } From 84589dc28804dd112c7474a1af6282ff99b2d3ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 0048/1386] #1284: double-clicking a shape tool adds a comp-sized shape layer Double-click a shape tool in the toolbar to add a shape layer the size of the composition, at its centre, for backgrounds, mattes and overlays, as After Effects does. It uses layer.newShape with the tool's kind, so the undo label and the command are the same as drawing one. Covered by tests::a_comp_sized_shape_layer_sits_on_the_comp_centre; the double-click itself is not covered by an automated test. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/engine/src/tests.rs | 12 ++++++++++++ crates/ui-egui/src/header.rs | 9 +++++++++ crates/ui-egui/src/panels/viewer.rs | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a85..680cbff22 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -246,6 +246,18 @@ fn text_shape_mask_matte_parent() { assert_eq!(info["layers"].as_array().unwrap().len(), 2); } +/// A shape layer the size of the comp and placed at its default position covers the frame: the +/// Shape Tool's double-click (#1284) makes one this way, for backgrounds, mattes and overlays. +#[test] +fn a_comp_sized_shape_layer_sits_on_the_comp_centre() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "size": [400, 300], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); + let pos = l.props.prop("transform/position").unwrap().value.clone(); + assert!(matches!(pos, effectcraft_keyframe::Value::Vec3(p) if (p[0] - 200.0).abs() < 1e-9 && (p[1] - 150.0).abs() < 1e-9), "{pos:?}"); +} + /// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn /// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include /// an empty Path and a Gradient Stroke that render. diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9e..0c6a9d169 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -82,6 +82,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { app.ui.status = e; } + // Double-click a shape tool: a shape layer filling the composition, centred, for + // backgrounds, mattes and overlays (as After Effects does). + if cur.is_shape() + && resp.double_clicked() + && let Some((w, h)) = app.session.active_comp().map(|c| (c.width, c.height)) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.newShape", json!({"kind": crate::panels::viewer::shape_kind(cur), "size": [w, h]})) + { + app.ui.status = e; + } if slot.len() > 1 { let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); if let Some(seconds) = held_for diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c9..8410bb78c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2138,7 +2138,7 @@ struct ShapeDrag { } /// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it. -fn shape_kind(tool: Tool) -> &'static str { +pub(crate) fn shape_kind(tool: Tool) -> &'static str { match tool { Tool::Rectangle => "rect", Tool::RoundedRect => "rounded", From f9c3547e57001ee9d876c1620e598a992a6d6b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:28:22 +0000 Subject: [PATCH 0049/1386] #1284: keep Rove Across Time covered through edits to other keys The report says a roving key goes back to its original time after other keys are edited. Editing the last key's value and then its time keeps the roving key at its share of the path, as the Rove Across Time description says, so this adds a regression test for both edits. It passes on this branch; the reported sequence is not reproduced yet, so the report still needs the exact steps. Claude-Session: https://claude.ai/code/session_0136kZtXrYo5VUWEFxWduwUD --- crates/engine/src/tests_timeline.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 6a74a23b3..94566240a 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -219,6 +219,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() { assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); } +/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the +/// roving key went back to where it was after edits to other keys). +#[test] +fn roving_key_follows_value_and_time_edits_to_other_keys() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]); + let uid = prop(&s, l, "transform/position").uid; + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap(); + s.execute("keys.interpolation", json!({"roving": true})).unwrap(); + let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds(); + assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s)); + // The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap(); + assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s)); + // The last key moves to 3 s: the same 300 / 800 share of 3 s. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap(); + assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s)); +} + #[test] fn graph_editor_key_and_handle_edits() { let (mut s, l) = setup(); From 8408af323a7526a484cca3f238adbc3e50c09562 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 14:21:14 -0600 Subject: [PATCH 0050/1386] Automation: confine every command that reads or writes a file it is given (#1243) essential.templateInfo was missing from confine.rs's READS, so its `path` got no named check, and the fallback only scans absolute-looking strings: a relative path (or one through `..`) escaped the read roots and the command parsed and returned a template outside them. An audit of every registered engine command with a parameter named like a file found the same gap in more commands, now classified: - READS: essential.templateInfo, file.importProject, file.watchFolder.poll, footage.relink, layer.newModel, mediaBrowser.fileInfo / list / import / removeFavorite, face.model.install, roto.model.install, templates.create (`path`). - WRITES: file.exportTimeline, file.createProxy (`path` / `output`, which may be a file-name template, as for render.preRender). - MIXED_WRITES: templates.create writes `projectPath` and `footageDir`. Tests (confine.rs): - every_command_with_file_parameters_is_classified fails when a command takes a path, paths, folder, file, output... parameter and is neither confined, refused, checked by a lookup, a Render Queue output (checked when the queue renders) nor listed in NOT_FILES (property paths and the like). New file commands can't slip through. - relative_and_outside_paths_of_newly_listed_commands_are_refused: relative, `..` and outside paths are refused and in-root ones allowed, directly and in a batch; writes go only to write roots; createProxy accepts a name template. It fails on main (templateInfo with `t.ectemplate` passes). Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/confine.rs | 148 ++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..7ae39acd4 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -64,8 +64,26 @@ const READS: &[(&str, &[&str])] = &[ ("effect.plugins.load", &["path", "folder"]), ("mediaBrowser.go", &["path"]), ("mediaBrowser.addFavorite", &["path"]), + // #1243: these read the files they are given but were missing, so a relative path went + // unchecked. + ("essential.templateInfo", &["path"]), + ("file.importProject", &["path"]), + ("file.watchFolder.poll", &["folder"]), + ("footage.relink", &["path"]), + ("layer.newModel", &["path"]), + ("mediaBrowser.fileInfo", &["path"]), + ("mediaBrowser.list", &["path"]), + ("mediaBrowser.import", &["paths"]), + ("mediaBrowser.removeFavorite", &["path"]), + ("face.model.install", &["path"]), + ("roto.model.install", &["path"]), + // Reads the template at `path`; what it writes is in `MIXED_WRITES`. + ("templates.create", &["path"]), ]; +/// Commands that read some parameters (listed in [`READS`]) and write others. +const MIXED_WRITES: &[(&str, &[&str])] = &[("templates.create", &["projectPath", "footageDir"])]; + /// Commands that write files named by these parameters. const WRITES: &[(&str, &[&str])] = &[ ("file.save", &["path"]), @@ -84,6 +102,9 @@ const WRITES: &[(&str, &[&str])] = &[ ("essential.exportTemplate", &["path"]), ("shortcuts.export", &["path"]), ("renderQueue.add", &["output", "path"]), + ("file.exportTimeline", &["path"]), + // `path` / `output` may also be a file-name template, as for render.preRender. + ("file.createProxy", &["path", "output"]), ]; /// Commands that write where they are not told: the project's file, the Render Queue's outputs. @@ -143,8 +164,9 @@ impl Confinement { let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; let inner_params = step.get("params").cloned().unwrap_or(Value::Null); let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + let mixed = keys_of(MIXED_WRITES, inner).unwrap_or(&[]); // A step's `$N` reference is resolved only as the batch runs: never for a file. - if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) { return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); } if self.check_command(inner, &inner_params)?.is_some() { @@ -170,7 +192,7 @@ impl Confinement { for p in paths { // A Render Queue output may name a template instead of a file. match p.as_str() { - Some(s) if id == "render.preRender" && !looks_like_path(s) => {} + Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {} Some(s) => { self.check(s, access)?; } @@ -178,9 +200,18 @@ impl Confinement { } } } + // Parameters of a reading command that it writes to. + let mixed = keys_of(MIXED_WRITES, id).unwrap_or(&[]); + for key in mixed { + if let Some(p) = params.get(*key).filter(|v| !v.is_null()) { + let p = p.as_str().ok_or_else(|| Error::BadArgs(format!("`{id}`: `{key}` must be a path")))?; + self.check(p, Access::Write)?; + } + } // Any other absolute path the command is given. + let named: Vec<&str> = named.iter().chain(mixed).copied().collect(); let mut other = vec![]; - absolute_strings(params, named, &mut other); + absolute_strings(params, &named, &mut other); for s in other { self.check(&s, access)?; } @@ -274,3 +305,114 @@ fn resolve(p: &Path) -> Option { at = at.parent()?; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Commands whose parameters are named like files but aren't: property paths + /// (`path|prop`), project folders, layers to track or target. + const NOT_FILES: &[&str] = &[ + "camera.createFromSolve", + "edit.label", + "edit.pasteReversedKeyframes", + "effect.editDropdown", + "essential.addProperty", + "essential.canAdd", + "essential.linkProperty", + "essential.unlinkProperty", + "keys.paste", + "keys.set", + "keys.setEase", + "keys.setSpatialTangents", + "paths.nullsFollowPoints", + "paths.pointsFollowNulls", + "paths.tracePath", + "project.move", + "prop.addKey", + "prop.convertExpressionToKeyframes", + "prop.deselect", + "prop.editGradient", + "prop.get", + "prop.pickWhip", + "prop.reset", + "prop.select", + "prop.set", + "prop.setExpression", + "prop.toggleAnimation", + "prop.toggleKey", + "track.new", + "track.property", + "track.setTarget", + ]; + + /// Every engine command with a parameter named like a file is classified, so a new one + /// can't silently skip confinement (#1243): it reads or writes the files it is given, is + /// refused, writes where it isn't told (checked by a lookup), sets Render Queue outputs + /// (checked when the queue renders), or is listed in `NOT_FILES`. + #[test] + fn every_command_with_file_parameters_is_classified() { + const FILE_KEYS: &[&str] = + &["path", "paths", "folder", "file", "files", "dir", "directory", "output", "outputs", "source", "target", "projectPath", "footageDir"]; + let mut missing = vec![]; + for spec in effectcraft_engine::command_specs() { + let Some(params) = effectcraft_engine::commands::accepted_params(spec.params) else { continue }; + let id = spec.id; + let classified = keys_of(READS, id).is_some() + || keys_of(WRITES, id).is_some() + || REFUSED.contains(&id) + || IMPLICIT_WRITES.contains(&id) + || id.starts_with("renderQueue.") + || id == "render.addOutputModule" + || NOT_FILES.contains(&id); + if params.iter().any(|p| FILE_KEYS.contains(&p.as_str())) && !classified { + missing.push(format!("{id} {}", spec.params)); + } + } + assert!(missing.is_empty(), "add these to READS / WRITES / REFUSED or NOT_FILES:\n{}", missing.join("\n")); + // And every listed command exists. + for id in READS.iter().chain(WRITES).chain(MIXED_WRITES).map(|(c, _)| *c).chain(NOT_FILES.iter().copied()) { + assert!(effectcraft_engine::command_specs().iter().any(|s| s.id == id), "{id} is not a command"); + } + } + + #[test] + fn relative_and_outside_paths_of_newly_listed_commands_are_refused() { + let base = std::env::temp_dir().join(format!("ec-confine-1243-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let c = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap(); + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + let up = format!("{}/../elsewhere/t.ectemplate", read.display()); + for (id, key) in [ + ("essential.templateInfo", "path"), + ("file.importProject", "path"), + ("footage.relink", "path"), + ("layer.newModel", "path"), + ("mediaBrowser.fileInfo", "path"), + ("mediaBrowser.list", "path"), + ("face.model.install", "path"), + ("templates.create", "path"), + ] { + for bad in ["t.ectemplate".to_string(), "../elsewhere/t.ectemplate".to_string(), up.clone(), p(&outside, "t.ectemplate")] { + assert!(c.check_command(id, &serde_json::json!({key: bad})).is_err(), "{id} {key}={bad}"); + } + assert!(c.check_command(id, &serde_json::json!({key: p(&read, "t.ectemplate")})).is_ok(), "{id} in a read root"); + } + // Batched too. + let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]}); + assert!(c.check_command("engine.batch", &steps).is_err()); + // Writes: into a write root only. + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err()); + assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project}); + assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath"); + assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok()); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": "[compName]_proxy"})).is_ok(), "a template name"); + assert!(c.check_command("file.createProxy", &serde_json::json!({"output": p(&outside, "proxy.mov")})).is_err()); + let _ = std::fs::remove_dir_all(&base); + } +} From 108ebd3eb823012d4c0b69380531eb59710fc454 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:41 +0200 Subject: [PATCH 0051/1386] Drop the root AGENTS.md docs rule; keep the PR to docs/user/ Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4dc6d10ac..aad29c94a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,14 +111,6 @@ Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. -## 4. User docs - -User docs live in `docs/user/` and are published at https://getartcraft.com/docs/effectcraft. If a -change alters something a user sees or does (menu or dialog labels, install, file formats, errors), -update the matching page in `docs/user/` in the same PR, or add one. Rules: `docs/user/AGENTS.md`. -Pages describe the latest release. For a change that is not released yet, write that it was added -after and is not in a release yet, or update the page when the release ships. - ## See also From b26a340a7e653cd2e0e70408a341e1ebbe5bc67d Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:26:42 -0600 Subject: [PATCH 0052/1386] Project: comp_contains walks precomps with a visited set, not recursion (#1237) Project::comp_contains recursed through precomp layers with no visited set or depth limit. A damaged project with a precomp cycle that doesn't pass the queried comp (A and B nesting each other, C unrelated) recursed until the stack ran out, which aborts the process ("stack overflow, aborting"): it can't be caught as a command error, so unsaved work is lost. layer.addItem checks it before adding a precomp, and the renderer calls it for every nested comp and its audio. A long chain used the stack the same way, and comps shared by many paths were walked once per path (exponential for nested diamonds). It is now an explicit stack with a visited set: same answers (a comp contains itself; a target reached through a cycle is found), each comp visited once, no recursion. The renderer already skips a precomp that contains the comp being rendered, so a cyclic project now renders instead of crashing; nothing is repaired on load. Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_ precomps (cycle with an unrelated target, self-nesting, a target through a cycle, 40 levels of diamonds, a 20 000-comp chain); engine a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_ out_of_stack (file.open, layer.addItem both ways, render). On main the engine test aborts with a stack overflow. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_limits.rs | 35 ++++++++++++++++++++ crates/project/src/lib.rs | 25 ++++++++++++-- crates/project/src/tests.rs | 55 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs index 68f2c04c0..524719bbf 100644 --- a/crates/engine/src/tests_limits.rs +++ b/crates/engine/src/tests_limits.rs @@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() { let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap(); assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } + +#[test] +fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { + // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A + // contains C walked the A ⇄ B cycle until the stack ran out (#1237). + let mut s = Session::default(); + let mut ids = vec![]; + for name in ["A", "B", "C"] { + s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + ids.push(s.active_comp_id().unwrap()); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + { + let proj = std::sync::Arc::make_mut(&mut s.project); + for (outer, inner) in [(a, b), (b, a)] { + let comp = proj.comp(outer).unwrap().clone(); + let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None); + let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(cc).layers.push(l); + } + } + let path = tmp("cycle.ecproj"); + std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + t.open_comp(c); + // Adding A into C asks whether A contains C: it doesn't, so the layer goes in. + let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap(); + assert!(r["layer"].as_u64().is_some(), "{r}"); + // Adding C into A would nest C in itself through A: refused, as before. + assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err()); + // C renders: inside A the renderer skips B's copy of A instead of looping. + let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (32, 32)); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..27f8870d4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1466,11 +1466,32 @@ impl Project { } /// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles). pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool { + // A walk with a stack and a visited set rather than recursion (#1237): a damaged project + // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long + // chain must not use up the call stack, and comps shared by many paths are visited once + // (recursion re-walked them for every path). if outer == inner { return true; } - let Some(c) = self.comp(outer) else { return false }; - c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner))) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![outer]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item } = l.source { + if item == inner { + return true; + } + if !seen.contains(&item) { + stack.push(item); + } + } + } + } + false } /// Make sure `next_id` exceeds every id in the project (after loading or merging). pub fn fix_next_id(&mut self) { diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c19d81ee2..508cb743d 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -206,3 +206,58 @@ fn item_users_count_layers_in_every_comp() { assert!(p.item_users(unused).is_empty()); assert!(p.item_users(main).is_empty()); } + +/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a` +/// (indexes into the returned ids). +fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) { + let mut p = Project::default(); + let ids: Vec = (0..n) + .map(|i| { + p.add_item( + &format!("C{i}"), + effectcraft_color::Label::None, + None, + ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()), + ) + }) + .collect(); + for &(a, b) in links { + let outer = p.comp(ids[a]).unwrap().clone(); + let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None); + let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(c).layers.push(l); + } + (p, ids) +} + +#[test] +fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { + // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237). + let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]); + assert!(!p.comp_contains(c[0], c[2])); + assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0])); + // A comp nesting itself. + let (p, c) = comp_graph(2, &[(0, 0)]); + assert!(!p.comp_contains(c[0], c[1])); + assert!(p.comp_contains(c[0], c[0])); + // A target reached through a cycle. + let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]); + assert!(p.comp_contains(c[0], c[2])); + assert!(!p.comp_contains(c[2], c[0])); + // 40 levels of diamonds: 2^40 paths, each comp visited once. + let mut links = vec![]; + for i in 0..40 { + let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3); + links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]); + } + let (p, c) = comp_graph(121, &links); + let t0 = std::time::Instant::now(); + assert!(p.comp_contains(c[0], c[120])); + assert!(!p.comp_contains(c[120], c[0])); + assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed()); + // A chain 20 000 comps deep (a deep recursion's stack frames). + let n = 20_000; + let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>()); + assert!(p.comp_contains(c[0], c[n - 1])); + assert!(!p.comp_contains(c[n - 1], c[0])); +} From 7b0771fe5acd5f69e8e6fe34722a88db2f09ddc3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0053/1386] Effects: a host evaluates one effect parameter at another layer time (#1373) EffectHost::param_at(effect_index, id, layer_time) evaluates a single parameter (by its flattened id, such as birthRate or group/param) of the effect at that index in the layer's stack. A simulation can then sample one parameter at every step without flattening the whole effect each time. The effect index is passed in, rather than read from the renderer's running-effect index as params_at does: GPU-batched effects never update that index, so a batched effect would read another effect's parameters. The renderer's implementation walks the effect group by match id and evaluates just that property (keyframes and expressions) at the comp time of the layer time. --- crates/effects/src/lib.rs | 7 +++++++ crates/render/src/lib.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..388b287c5 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -383,6 +383,13 @@ pub trait EffectHost: Sync { fn params_at(&self, _layer_time: f64) -> Option { None } + /// Parameter `id` (a [`flatten_params`] id such as `birthRate`) of effect `effect_index` in + /// the layer's stack (the running effect's [`EffectEnv::effect_index`]) at another **layer + /// time**: CC Particle World samples its Birth Rate at every step it simulates (#1373). + /// `None` when unavailable. + fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { + None + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..a5597de93 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -343,6 +343,27 @@ impl EffectHost for FxHost<'_, '_, '_> { Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) } + fn param_at(&self, effect_index: usize, id: &str, layer_time: f64) -> Option { + // Only the one property: a simulation samples it at every step. + let mut g = self.layer.effects()?.groups().nth(effect_index)?; + let mut path = id.split('/').peekable(); + while let Some(m) = path.next() { + if path.peek().is_none() { + let p = g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p), + _ => None, + })?; + let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time))); + return Some(ctx.value(self.layer, p)); + } + g = g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg), + _ => None, + })?; + } + None + } + fn comp_scene(&self) -> Option { Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin)) } From 2e654f92c1eedb8a237c68442a3f243481a59bfe Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0054/1386] Effects: SimCache can resume only from checkpoints reached with the same inputs (#1373) SimCache::run_where takes a check on each checkpoint (its step and state). A simulation whose steps depend on inputs outside its key, such as a parameter's values over time, resumes only from a checkpoint reached with the same inputs. A fresh checkpoint now replaces one at the same step, so a changed history doesn't leave stale checkpoints behind. run() is run_where with no check. --- crates/effects/src/util.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 1527e221f..12c7e8b1b 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -534,14 +534,29 @@ impl SimCache { /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step /// `i` to `i + 1`. - pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + self.run_where(key, target, init, step, |_, _| true) + } + + /// [`SimCache::run`] for a simulation whose steps also depend on inputs outside `key` (a + /// parameter's values over time): a checkpoint after `s` steps is only resumed from when + /// `valid(s, state)` says it was reached with the same inputs. Fresh checkpoints replace + /// those at the same step. + pub fn run_where( + &self, + key: u64, + target: u64, + init: impl FnOnce() -> S, + mut step: impl FnMut(&mut S, u64), + valid: impl Fn(u64, &S) -> bool, + ) -> std::sync::Arc { use std::sync::Arc; let start = { let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; g.iter_mut().find(|e| e.key == key).and_then(|e| { e.used = tick; - e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + e.checkpoints.iter().filter(|(s, st)| *s <= target && valid(*s, st)).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) }) }; let had_start = start.is_some(); @@ -579,9 +594,8 @@ impl SimCache { let e = &mut g[idx]; e.used = tick; for (s, st) in fresh { - if !e.checkpoints.iter().any(|(c, _)| *c == s) { - e.checkpoints.push((s, st)); - } + e.checkpoints.retain(|(c, _)| *c != s); + e.checkpoints.push((s, st)); } // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. if e.checkpoints.len() > 256 { From a00a76e7ed25f3e6576d7fced9e0ae317a9ca9da Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0055/1386] Particles: a birth schedule from a rate per step (#1373) psim::births_at builds the GPU's birth schedule (the birth step of every particle id) from the rate of each step, with the CPU step's carry arithmetic. births() for a constant rate now uses it. It stops as soon as the cap would be passed, before adding a step's births, so a huge rate can't build a huge list first. --- crates/effects/src/psim.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs index aabbcdce8..5716a2dcb 100644 --- a/crates/effects/src/psim.rs +++ b/crates/effects/src/psim.rs @@ -85,21 +85,27 @@ pub trait ParticleSim: Send + Sync { /// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the /// CPU drops births at its live-particle cap, which an independent simulation can't know). pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { - let per = rate / SPS; - if per * steps as f64 > cap as f64 + 2.0 { + if rate / SPS * steps as f64 > cap as f64 + 2.0 { return None; } + births_at(steps, cap, |_| rate) +} + +/// [`births`] with the rate of each step, `rate(i)` particles per second during step `i` (a +/// keyframed Birth Rate, #1373). +pub fn births_at(steps: u64, cap: usize, rate: impl Fn(u64) -> f64) -> Option> { let mut out = Vec::new(); let mut carry = 0.0f64; for i in 0..steps { - carry += per; + carry += rate(i) / SPS; let n = carry.floor() as u32; carry -= n as f64; - for _ in 0..n { - out.push(i as u32); + if n as usize > cap.saturating_sub(out.len()) { + return None; } + out.extend(std::iter::repeat_n(i as u32, n as usize)); } - (out.len() <= cap).then_some(out) + Some(out) } /// Steps per second of the stepped simulations. From bb0e2b57db227639a559615fd01aae6d659022b7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0056/1386] CC Particle World: Birth Rate decides the births of its own time, so a burst lives on (#1373) The simulation runs from layer time 0, but it used the Birth Rate of the rendered frame for every step. A rate keyed from 3 down to 0 simulated no births at all once it reached 0, so the burst's particles vanished that frame although their Longevity had not run out. A ramped rate was simulated as if its current value had always applied. Each step now samples Birth Rate at its own layer time through EffectHost::param_at (with the effect's own index). Without such a host the current value applies throughout, as before. Particles already alive keep moving and ageing whatever the rate. The carry and particle ids continue across rate changes. Birth Rate is left out of the simulation cache key, so an animated rate no longer restarts the simulation every frame. In its place: - The CPU state keeps a hash of the rates it was stepped with (hist). A checkpoint is resumed only when that matches the current history, so moving an earlier key is never served from a stale checkpoint. - The GPU gets the birth schedule of the sampled rates (births_at). Its checkpoints record which births they hold, and are resumed only when the request's births before that step are the same. A constant Birth Rate gives exactly the particles it did. CC Particle Systems II and CC Mr. Mercury keep their constant-rate simulation. --- crates/effects/src/sim.rs | 86 +++++++++++++++++++++++++++++++------ crates/gpu/src/particles.rs | 20 ++++++++- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 2c7b97d14..8bb3c2ea7 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -784,6 +784,14 @@ pub(crate) struct PState { pub parts: Vec, pub carry: f64, pub next_id: u32, + /// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed + /// from with the same history. + pub hist: u64, +} + +/// `hist` after one more step at `rate` particles per second. +fn rates_hash(hist: u64, rate: f64) -> u64 { + (hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5) } /// Physics configuration (world units; y down). @@ -862,6 +870,12 @@ impl Phys { } pub(crate) fn step(&self, st: &mut PState, step: u64) { + self.step_at_rate(st, step, self.rate); + } + + /// One step with `rate` particles per second born during it (rather than [`Phys::rate`]): + /// the rate only decides the births, particles already alive move and age as ever. + pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) { let dt = (1.0 / SPS) as f32; let t = step as f32 * dt; let mut drag = self.resistance; @@ -919,7 +933,8 @@ impl Phys { q.age += dt; }); st.parts.retain(|q| q.age < q.life); - st.carry += self.rate / SPS; + st.hist = rates_hash(st.hist, rate); + st.carry += rate / SPS; let n = st.carry.floor() as u32; st.carry -= n as f64; for _ in 0..n { @@ -966,31 +981,73 @@ impl Phys { } /// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or -/// `None` (simulate on the CPU). -fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { +/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time +/// (else [`Phys::rate`]). +fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option> { let backend = ctx.env.host?.particles()?; let steps = steps_at(ctx.time); - let births = crate::psim::births(phys.rate, steps, phys.max)?; + let births = match rates { + Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?, + None => crate::psim::births(phys.rate, steps, phys.max)?, + }; let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; let parts = backend.simulate(&req)?; Some(std::sync::Arc::new(PState { parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), carry: 0.0, next_id: births.len() as u32, + hist: 0, })) } +/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter +/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only +/// decides the births of its own time, #1373). Without a host that evaluates the effect at +/// other times, the current value for every step. +fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec { + let now = ctx.params.f(id); + let host = ctx.env.host; + (0..steps_at(ctx.time)) + .map(|i| { + let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64()); + if v.is_finite() { v.max(0.0) * k } else { 0.0 } + }) + .collect() +} + +/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with +/// `cache`, resuming only from checkpoints reached with the same rates. +fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc { + // `hist` of the state after each number of steps. + let mut prefix = Vec::with_capacity(rates.len() + 1); + prefix.push(0u64); + for r in rates { + prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r)); + } + let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0); + cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist)) +} + +/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time. +fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + let rates = rate_history(ctx, "birthRate", PW_RATE); + // Every parameter but Birth Rate (its history is checked through `PState::hist`). + let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); + accelerated(ctx, key, phys, Some(&rates)).unwrap_or_else(|| simulate_rates(&PW_CACHE, key, phys, &rates)) +} + /// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, /// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has /// one (GPU particles), else from the CPU simulation. For comparing the two. pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { - let (phys, cache, salt) = match id { - "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), - "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + let st = match id { + "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)), + "ec.sim.ccparticlesystems2" => { + let (phys, key) = (ps2_phys(ctx), sim_key(ctx, 2)); + accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)) + } _ => return None, }; - let key = sim_key(ctx, salt); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) } @@ -1042,11 +1099,14 @@ fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, static PW_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle World's Birth Rate. +const PW_RATE: f64 = 120.0; + /// CC Particle World's physics. fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; Phys { - rate: pr.f("birthRate").max(0.0) * 120.0, + rate: pr.f("birthRate").max(0.0) * PW_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32], @@ -1167,8 +1227,7 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { if phys.rate <= 0.0 && ctx.time <= 0.0 { return None; } - let key = sim_key(ctx, 1); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys)); + let st = pw_state(ctx, &phys); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -1242,7 +1301,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let unit = lh.max(1.0); let phys = ps2_phys(ctx); let key = sim_key(ctx, 2); - let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); + let st = accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -1969,4 +2028,5 @@ mod tests { let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); } + } diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 7934dee0c..837746547 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -23,10 +23,25 @@ struct Checkpoint { key: u64, steps: u64, n: u32, + /// The birth steps of its particles (see [`born`]). + born: u64, buf: wgpu::Buffer, used: u64, } +/// The particles born in the first `steps` steps of `births` (ascending birth steps) and a hash +/// of their birth steps: a checkpoint is only resumed from when the request's births before it +/// are the same (a keyframed Birth Rate isn't part of the system key, #1373). +fn born(births: &[u32], steps: u64) -> (u32, u64) { + let mut h = 0xcbf2_9ce4_8422_2325u64; + let mut n = 0u32; + for &b in births.iter().take_while(|&&b| u64::from(b) < steps) { + h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3); + n += 1; + } + (n, h) +} + #[derive(Default)] pub(crate) struct Checkpoints { list: Vec, @@ -141,7 +156,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option { k.used = now; (k.steps, Some(k.buf.clone()), k.n) @@ -232,7 +247,8 @@ fn keep_checkpoint(g: &GpuContext, req: &SimRequest, n: u32, next: wgpu::Buffer) if let Ok(mut c) = g.particle_states.lock() { let now = c.clock; c.list.retain(|k| !(k.key == req.key && k.steps == req.steps)); - c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now }); + let (_, h) = born(req.births, req.steps); + c.list.push(Checkpoint { key: req.key, steps: req.steps, n, born: h, buf: next, used: now }); while c.list.len() > CHECKPOINTS { let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; c.list.remove(i); From d5a1268ead73cda62f89500e959da60b11e74a72 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0057/1386] CC Particle World: tests for a Birth Rate that changes over time (#1373) A burst lives on after the rate drops to 0: as a constant rate during it, only its particles afterwards, still moving, gone after their Longevity. Another effect's history isn't used. A ramp bears what each moment asked for. Seeking backward and forward matches a simulation from 0. Moving an earlier key isn't served from stale checkpoints with the same current rate. A constant rate is unchanged. The birth schedule matches the CPU's ids. --- crates/effects/src/sim.rs | 155 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 8bb3c2ea7..15544eb81 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -2029,4 +2029,159 @@ mod tests { assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); } + // ---------------------------------------------------------- Birth Rate over time (#1373) + + /// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`. + struct Rate { + index: usize, + rate: fn(f64) -> f64, + } + impl crate::EffectHost for Rate { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option { + (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t))) + } + } + + /// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst). + fn burst(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } + } + + /// The same burst, cut at 0.25 s (an earlier key moved). + fn short_burst(t: f64) -> f64 { + if t < 0.25 { 3.0 } else { 0.0 } + } + + /// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s. + fn falling(t: f64) -> f64 { + (3.0 * (1.0 - t / 0.5)).max(0.0) + } + + /// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the + /// rendered time), Longevity 1 s. + fn pw_params(seed: f64, now: f64) -> crate::Params { + let spec = crate::find("ec.sim.ccparticleworld").unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] { + p.values.insert(k.to_string(), v); + } + p + } + + fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env } + } + + /// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history. + fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec { + let p = pw_params(seed, now); + particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap() + } + + /// The same, simulated from layer time 0 with a cache of its own (the reference). + fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec { + let p = pw_params(seed, now); + let ctx = pw_ctx(&p, t, Some(host), host.index); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); + let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE)); + st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect() + } + + /// Particles born in the first `t` seconds of `rate`. + fn born_by(rate: fn(f64) -> f64, t: f64) -> usize { + crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len() + } + + /// #1373: Birth Rate only decides the births of its own time. After a burst stops, the + /// particles born in it live on (moving and ageing) until their Longevity runs out, rather + /// than vanishing because the rate is now 0. + #[test] + fn a_burst_lives_on_after_birth_rate_drops_to_zero() { + let host = Rate { index: 2, rate: burst }; + // During the burst: as a constant Birth Rate of 3. + assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None)); + // After it: the burst's particles, none newer. + let late = pw_at(101.0, 0.0, 0.9, Some(&host)); + let n = born_by(burst, 0.5); + assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len()); + assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any"); + // They keep moving. + let early = pw_at(101.0, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.id == late[0].id).unwrap(); + assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]); + // Longevity 1 s (± 20 %): all gone by 1.8 s. + assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty()); + // The effect asks for its own index: another effect's history isn't its own. + let p = pw_params(101.0, 0.0); + assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty()); + } + + /// A Birth Rate ramping down isn't simulated as if its current value had always applied. + #[test] + fn a_ramped_birth_rate_bears_what_each_moment_asked_for() { + let host = Rate { index: 0, rate: falling }; + let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host)); + let constant = pw_at(102.0, falling(0.25), 0.25, None); + assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive"); + assert_eq!(constant.len(), born_by(|_| 1.5, 0.25)); + assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len()); + } + + /// Seeking backward, forward and to the same time again gives the state simulated from 0. + #[test] + fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() { + let host = Rate { index: 0, rate: burst }; + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}"); + } + } + + /// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints), + /// but a checkpoint is only resumed with the history that reached it: after an earlier key + /// moves, the same current rate (0) gives the new history's particles. + #[test] + fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + let a = pw_at(104.0, 0.0, 0.9, Some(&long)); + let b = pw_at(104.0, 0.0, 0.9, Some(&short)); + assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short)); + assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len()); + assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long)); + } + + /// A constant Birth Rate gives exactly the particles it did before the history was sampled. + #[test] + fn a_constant_birth_rate_is_unchanged() { + let host = Rate { index: 0, rate: |_| 2.0 }; + let p = pw_params(105.0, 2.0); + let ctx = pw_ctx(&p, 1.3, None, 0); + let phys = pw_phys(&ctx); + let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys); + let now = pw_at(105.0, 2.0, 1.3, Some(&host)); + assert_eq!(now.len(), before.parts.len()); + assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age)); + assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000)); + } + + /// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the + /// CPU simulation numbers. + #[test] + fn the_birth_schedule_follows_the_rate_history() { + let host = Rate { index: 0, rate: falling }; + let p = pw_params(106.0, 0.0); + let ctx = pw_ctx(&p, 0.7, Some(&host), 0); + let rates = rate_history(&ctx, "birthRate", PW_RATE); + let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates); + let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap(); + assert_eq!(st.next_id as usize, births.len()); + assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); + } } From 8f03f24247bf9c3df1af685e42703d46b044953f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 0058/1386] Render: tests for a CC Particle World burst through the renderer (#1373) The issue's scene: a Birth Rate keyed 3 until 0.5 s then 0 matches the constant rate during the burst, still draws the burst at 0.9 s with no new births, and is empty once Longevity has run out. Moving the earlier key changes the 0.9 s frame through the layer cache. Layers offset by 0.5 s or stretched 200 % sample their own layer times. --- crates/render/src/tests.rs | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 39e022b04..add2ec634 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -312,6 +312,69 @@ fn particle_world_follows_the_comp_camera() { assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}"); } +/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a +/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. +fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))]; + add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals); + if let Some(cut) = cut { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let fx = fx.unwrap(); + let rate = fx.prop_mut("birthRate").unwrap(); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + rate.keys = vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)]; + } + l.start_time = Tick::from_seconds_f64(start); + l.in_point = l.start_time; + l.out_point = Tick::from_seconds_f64(4.0); + l.stretch = stretch; + p.comp_mut(cid).unwrap().layers = vec![l]; + (p, cid) +} + +/// Total light in a frame. +fn light(img: &crate::Image) -> f32 { + img.data.iter().map(|p| p[0] + p[1] + p[2]).sum() +} + +/// #1373: a Birth Rate keyed down to 0 stops new births; the particles already born live on +/// until their Longevity runs out (they vanished the frame the rate reached 0). +#[test] +fn particle_world_keeps_a_burst_after_birth_rate_drops_to_zero() { + let (keyed, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (constant, _) = burst_scene(None, 0.0, 100.0); + let at = Tick::from_seconds_f64; + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), "during the burst"); + let (k, c) = (light(&render_cached(&keyed, cid, at(0.9), None)), light(&render_cached(&constant, cid, at(0.9), None))); + assert!(k > 1.0 && k < c, "the burst is still drawn, with no new births: {k} vs {c}"); + // Longevity 1 s (± 20 %): gone by 1.9 s. + assert_eq!(light(&render_cached(&keyed, cid, at(1.9), None)), 0.0); +} + +/// The burst's history: moving an earlier key changes a later frame whose Birth Rate is the +/// same (0), cached or not, and offset or stretched layers sample their own layer times. +#[test] +fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() { + let at = Tick::from_seconds_f64; + let (long, cid) = burst_scene(Some(0.5), 0.0, 100.0); + let (short, _) = burst_scene(Some(0.25), 0.0, 100.0); + let cache = crate::LayerCache::default(); + let a = render_cached(&long, cid, at(0.9), Some(&cache)); + let b = render_cached(&short, cid, at(0.9), Some(&cache)); + assert_same(&b, &render_cached(&short, cid, at(0.9), None), "cached render after an earlier key moved"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "a shorter burst: {} vs {}", light(&b), light(&a)); + let plain = render_cached(&long, cid, at(0.9), None); + let (late, _) = burst_scene(Some(0.5), 0.5, 100.0); + assert_same(&render_cached(&late, cid, at(1.4), None), &plain, "a layer starting at 0.5 s"); + let (slow, _) = burst_scene(Some(0.5), 0.0, 200.0); + assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %"); +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { From 1cea7d774f0025410db3e510b4af7e44abe4c67a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:21 -0400 Subject: [PATCH 0059/1386] GPU: test GPU particles against the CPU with a keyed Birth Rate (#1373) With a Birth Rate keyed into a burst, the GPU's particles match the CPU simulation through the burst and after it. A second history with the same system key, whose burst starts later (so the same ids are born at other steps), isn't resumed from the first history's GPU checkpoints. --- crates/gpu/src/tests_particles.rs | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs index d2a229b0f..c27b2da70 100644 --- a/crates/gpu/src/tests_particles.rs +++ b/crates/gpu/src/tests_particles.rs @@ -91,6 +91,59 @@ fn particle_world_matches_the_cpu() { } } +/// A host whose Birth Rate is `rate(layer time)` (#1373), with the GPU backend or without (the +/// CPU oracle). +struct Keyed { + gpu: Option<&'static Gpu>, + rate: fn(f64) -> f64, +} + +impl EffectHost for Keyed { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + self.gpu.map(|g| g as &dyn ParticleSim) + } + fn param_at(&self, _effect_index: usize, id: &str, t: f64) -> Option { + (id == "birthRate").then(|| n((self.rate)(t))) + } +} + +/// CC Particle World's live particles at layer time `t` through `host`. +fn keyed_state(p: &Params, t: f64, host: &Keyed) -> Vec { + let env = EffectEnv { host: Some(host), ..Default::default() }; + let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + effectcraft_effects::particle_state("ec.sim.ccparticleworld", &ctx).unwrap() +} + +/// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU +/// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one +/// history is not resumed for another (the burst's start moved, so the same ids are born at +/// other steps; same system key). +#[test] +fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + fn long(t: f64) -> f64 { + if t < 0.5 { 4.0 } else { 0.0 } + } + fn late(t: f64) -> f64 { + if (0.2..0.5).contains(&t) { 4.0 } else { 0.0 } + } + let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]); + for (rate, times) in [(long as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { + let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); + for t in times { + let cpu = keyed_state(&p, t, &cpu_host); + let gpu = keyed_state(&p, t, &gpu_host); + agree(&format!("keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); + } + } +} + #[test] fn particle_systems_ii_matches_the_cpu() { let Some(g) = gpu() else { return }; From 2b88724b43d70b45a6a079a28caed832e905110e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:21 -0400 Subject: [PATCH 0060/1386] Docs: architecture.md notes CC Particle World's Birth Rate history and checkpoint checks (#1373) --- docs/architecture.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22a..1e0a2e804 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,6 +368,10 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a +keyed rate only decides the births of its own time; the rate is left out of the simulation key, +and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +checkpoint's births). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an adjustment-layer comp. From b51c91a126e9a3a100057bd51132f7060d6a0c63 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 12:14:27 +1100 Subject: [PATCH 0061/1386] Render: ask the effect spec whether a stack can skip the layer cover Matej's follow-up kept the known-margin set as SKIPS_COVER and the renderer looked the ids up. Ask EffectSpec::skips_cover instead so a new margin effect opts in on the spec rather than in run_effect_stack. Tests pin align_plan to cover_shift mod 64 and that Twirl still takes the full cover. --- crates/effects/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/render/src/lib.rs | 6 ++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a968f1aad..cf59a56c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -514,6 +514,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1504,4 +1513,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 041052b8e..313cb73ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1150,14 +1150,12 @@ impl<'a> Renderer<'a> { // get the cover: they move content past the painted bounds without growing the buffer. let video = || { fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { - GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { - Some(effect.as_str()) - } + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), _ => None, }) }; if !adjustment && video().next().is_some() { - if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + if video().all(|s| s.skips_cover()) { target.align(layer_size); } else { target.cover(layer_size); From f2cdad9edbc787929987470a20382d252ba7344d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:57:19 -0400 Subject: [PATCH 0062/1386] Expressions: pick-whip references to effect properties by index (refs #1072) In After Effects, holding Alt while dropping the expression pick whip refers to an effect's property by its index, effect("Color Control")(1), instead of its name, ("Color"). prop.pickWhip takes byIndex: the effect keeps its name and each property inside it is selected by its one-based place among its group's children (nested groups too), which is what an expression's (1) reads. Other properties, dimensions ([1]), insertion at the cursor and match names (Compact English off) are as before. An index still reads a renamed property, but another one if properties are reordered. Tests: pick_whip_by_index_selects_effect_properties_by_their_index (engine, the references); effectcraft-host pick_whip (by index and by name read the same values, nested and per dimension). --- crates/engine/src/commands/link.rs | 43 +++++++++++++++++++---- crates/engine/src/tests_timeline.rs | 53 +++++++++++++++++++++++++++++ crates/host/tests/pick_whip.rs | 38 +++++++++++++++++++++ docs/agents.md | 4 ++- 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 crates/host/tests/pick_whip.rs diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs index 2eeb7c5a0..66145f17e 100644 --- a/crates/engine/src/commands/link.rs +++ b/crates/engine/src/commands/link.rs @@ -102,11 +102,39 @@ fn quote(s: &str) -> String { /// The AE expression reference to property `uid` of `target` as seen from a property of `from`. pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option { + reference_with(comp, from, target, uid, compact, false) +} + +/// [`reference`], with the properties inside an effect selected by their index (`by_index`, an +/// Alt pick whip in After Effects): `effect("Color Control")(1)` rather than `("Color")`. The +/// effect keeps its name. An index is the property's place among its group's children, as an +/// expression's `(1)` reads it: it follows a renamed parameter, not one moved to another place. +pub(crate) fn reference_with(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool, by_index: bool) -> Option { let chain = target.props.node_chain(uid)?; + // Each node's one-based index among its group's children, and whether it lies inside an + // effect (below the effect itself): those are selected by index with `by_index`. + let mut parent = &target.props; + let mut depth_in_effects: Option = None; + let mut by_number: Vec> = vec![]; + for n in &chain { + let at = parent.children.iter().position(|c| c.uid() == n.uid()).map(|i| i + 1); + by_number.push(at.filter(|_| by_index && depth_in_effects.is_some_and(|d| d >= 1))); + depth_in_effects = match depth_in_effects { + Some(d) => Some(d + 1), + None => (n.match_id() == "effects").then_some(0), + }; + if let Node::Group(g) = n { + parent = g; + } + } + let selector = |i: usize, label: &str| match by_number.get(i).copied().flatten() { + Some(at) => format!("({at})"), + None => format!("({})", quote(label)), + }; let mut parts: Vec = vec![]; let mut pending: Option<&str> = None; let mut in_effect = false; - for n in chain { + for (i, n) in chain.iter().copied().enumerate() { if let Some(f) = pending.take() { parts.push(format!("{f}({})", quote(n.name()))); in_effect |= f == "effect"; @@ -119,7 +147,7 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com (Node::Group(_), "transform") if !in_effect => parts.push("transform".into()), (Node::Group(_), "text") if !in_effect => parts.push("text".into()), (Node::Prop(_), "sourceText") => parts.push("sourceText".into()), - _ if in_effect => parts.push(format!("({})", quote(n.name()))), + _ if in_effect => parts.push(selector(i, n.name())), _ => parts.push(camel(n.name())), } } @@ -127,10 +155,9 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, com let _ = comp; if !compact { // Match names: language-independent, like After Effects with Compact English off. - let chain = target.props.node_chain(uid)?; let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) }; - for n in chain { - out.push_str(&format!("({})", quote(n.match_id()))); + for (i, n) in chain.iter().copied().enumerate() { + out.push_str(&selector(i, n.match_id())); } return Some(out); } @@ -161,7 +188,9 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let from = comp.layer(lid).ok_or(EngineError::NoComp)?; let target = comp.layer(tl).ok_or(EngineError::NoComp)?; - let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let by_index = b_p(p, "byIndex").unwrap_or(false); + let r = reference_with(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact, by_index) + .ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); // Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects. @@ -206,7 +235,7 @@ pub fn specs() -> Vec { "Pick Whip (Link Property)", [], None, - "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)", + "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end], byIndex?: bool} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end; `byIndex`: an effect's properties by their index, `effect(\"Color Control\")(1)`, as an Alt pick whip does)", has_layers, pick_whip ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c0..949351b57 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -570,6 +570,59 @@ fn pick_whip_generates_ae_reference_expressions() { assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); } +/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index, +/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The +/// effect keeps its name; other properties, dimensions, insertion into an expression and match +/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the +/// references read the right values.) +#[test] +fn pick_whip_by_index_selects_effect_properties_by_their_index() { + let (mut s, l) = setup(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Color Control", "Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + // Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree. + let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone(); + let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1; + let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1; + let whip = |s: &mut Session, path: &str, target: Value, extra: Value| { + let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("prop.pickWhip", p).unwrap() + }; + let other = "thisComp.layer(\"Other\")"; + let undo = s.history.undo.len(); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})), + json!(format!("{other}.effect(\"Color Control\")(1)[0]")), + "a colour into one value: its first" + ); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})"); + assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested)); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})), + json!(format!("{other}.effect(\"Point Control\")(1)[1]")) + ); + assert_eq!( + whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})), + json!(format!("1 + {nested}")), + "inserted at the cursor" + ); + assert_eq!( + whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})), + json!(format!("{other}.transform.opacity")), + "only an effect's properties go by index" + ); + // Match names (Compact English off): the effect by its match name, its properties by index. + s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap(); + let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})); + assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick"); +} + /// #407: Reset without a value gives a property its own default (a Transform property's, an /// effect parameter's) besides removing its keyframes and expression. #[test] diff --git a/crates/host/tests/pick_whip.rs b/crates/host/tests/pick_whip.rs new file mode 100644 index 000000000..340cda053 --- /dev/null +++ b/crates/host/tests/pick_whip.rs @@ -0,0 +1,38 @@ +//! The pick whip's references read the property they were made from (#1072): an Alt pick whip's +//! (`byIndex`) select an effect's properties by index, `effect("Fractal Noise")(i)(j)`, and must +//! read the same value as the reference by name. +//! +//! (With Compact English off, a reference to another layer, `thisComp.layer("Other")("effects")…`, +//! doesn't evaluate yet, by name or by index: "not a callable function".) + +use serde_json::{Value, json}; + +#[test] +fn index_references_read_the_same_values_as_names() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "T", "width": 64, "height": 64, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Reader", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Fractal Noise", "Point Control"] { + s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap(); + } + s.execute("prop.set", json!({"layer": o, "path": "effects/#1/transform/scale", "value": 77})).unwrap(); + s.execute("prop.set", json!({"layer": o, "path": "effects/#2/point", "value": [12, 34]})).unwrap(); + let evaluated = |s: &mut effectcraft_engine::Session, path: &str| -> Value { + let v = s.execute("prop.get", json!({"layer": l, "path": path})).unwrap(); + assert!(v.get("expressionError").is_none(), "{v}"); + v["evaluated"].clone() + }; + for (path, target, dimension, want) in + [("transform/rotation", "effects/#1/transform/scale", None, json!(77.0)), ("transform/opacity", "effects/#2/point", Some(1), json!(34.0))] + { + let mut t = json!({"layer": o, "path": target}); + if let Some(d) = dimension { + t["dimension"] = json!(d); + } + for by_index in [false, true] { + let e = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": t, "byIndex": by_index})).unwrap(); + assert_eq!(evaluated(&mut s, path), want, "by index {by_index}: {e}"); + } + } +} diff --git a/docs/agents.md b/docs/agents.md index 1bec71d51..d7d9142ee 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -604,7 +604,9 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a - drop on that value field does. + drop on that value field does. `byIndex: true` (an Alt pick whip: Alt held as it drops) refers + to an effect's properties by their index, `effect("Color Control")(1)`: it still reads a + renamed property, but another one if properties are reordered. * Every property row has a property pick whip in the Parent & Link column (`timeline.prop..propertyPickWhip`; the expression row's is `timeline.prop..pickWhip`). Dragged onto another property's name or value, in the Timeline or Effect Controls From 25fc51f3ed78016553ea0542a943f6443c220a3a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:57:19 -0400 Subject: [PATCH 0063/1386] Timeline: Alt held as a pick whip drops refers by index (refs #1072) A property or expression pick whip dropped with Alt held, onto the Timeline or Effect Controls, now sends byIndex: an effect's property is referred to by its index, as in After Effects. Test: an_alt_pick_whip_refers_to_an_effect_property_by_index (Alt and plain drops onto Effect Controls and the Timeline, into the expression being edited with a dimension, one undo step each). --- crates/ui-egui/src/panels/timeline.rs | 4 ++ crates/ui-egui/tests/ui_inline_edit.rs | 68 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..3867e222f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3052,6 +3052,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(d) = d { params["target"]["dimension"] = json!(d); } + // Alt held as it drops: an effect's properties by their index (#1072). + if ctx.input(|i| i.modifiers.alt) { + params["byIndex"] = json!(true); + } if let Some((text, [a, b])) = into { // Into the expression being edited, which goes on being edited. params["expression"] = json!(text); diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..4b6c03a36 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,71 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// A primary-button drag from `from` to `to` with `modifiers` held from the press to the release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: with Alt held as it drops, a pick whip refers to an effect's property by its index, +/// `effect("Slider Control")(1)`, as in After Effects; without Alt, by its name. Onto Effect +/// Controls or the Timeline, and into the expression being edited (with a dimension). +#[test] +fn an_alt_pick_whip_refers_to_an_effect_property_by_index() { + let (mut h, _) = harness(); + let (layer, opacity, transform, effects) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let props = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.clone(); + let effects: Vec = std::iter::once(props.sub("effects").unwrap().uid).chain(props.sub("effects").unwrap().groups().map(|g| g.uid)).collect(); + (l, props.sub("transform").unwrap().get("opacity").unwrap().uid, props.sub("transform").unwrap().uid, effects) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().ui.timeline.open_groups.extend(effects); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let (slider, point) = (props(&h).prop("effects/#1/slider").unwrap().uid, props(&h).prop("effects/#2/point").unwrap().uid); + let expr = |h: &Harness<'_, EffectcraftApp>| props(h).find(opacity).unwrap().expr.clone().map(|e| e.text); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let whip = |h: &mut Harness<'_, EffectcraftApp>, target: &str, modifiers: Modifiers| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.propertyPickWhip")), center(h, target)); + drag_with(h, from, to, modifiers); + }; + whip(&mut h, &format!("effectControls.prop.{slider}.value"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto Effect Controls"); + let undo = h.state().session.history.undo.len(); + whip(&mut h, &format!("effectControls.prop.{slider}.value"), Modifiers::NONE); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(\"Slider\")"), "without Alt, by name"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); + whip(&mut h, &format!("timeline.prop.{slider}.name"), alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "Alt onto the Timeline"); + // Into the expression being edited, at the cursor, with the value it was dropped on. + let p = center(&h, &format!("timeline.prop.{opacity}.expression")); + click(&mut h, p, 1); + key(&mut h, Key::End); + type_text(&mut h, " + "); + let (from, to) = (center(&h, &format!("timeline.prop.{opacity}.pickWhip")), center(&h, &format!("effectControls.prop.{point}.value.1"))); + drag_with(&mut h, from, to, alt); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1) + effect(\"Point Control\")(1)[1]")); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(expr(&h).as_deref(), Some("effect(\"Slider Control\")(1)"), "undone in one step"); +} From f263a32ab7eb4168c5bb7035c2a5ad83c06f0db2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:59:03 -0400 Subject: [PATCH 0064/1386] Render: an effect parameter's history is read at its exact layer time (#1373) FxHost::param_at mapped the requested layer time to comp time, and EvalCtx::value mapped it back. Under a fractional stretch (33.3 %, 133.33 %) that round trip truncates a tick early, so a Hold key at 0.5 s read its previous value at the step starting there: a Birth Rate cut to 0 at 0.5 s bore one more step of particles (six). EvalCtx::value_at_layer_time reads the keyframes at the given layer time exactly, and still runs an expression at the comp time. EvalCtx::value now uses it, and param_at reads through it. EffectHost::param_is_static says when a parameter has no keyframes and no expression. CC Particle World then skips sampling its Birth Rate at every step, since it's the same throughout. --- crates/effects/src/lib.rs | 5 ++++ crates/effects/src/sim.rs | 3 ++- crates/render/src/eval.rs | 8 ++++++- crates/render/src/lib.rs | 48 ++++++++++++++++++++++++--------------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 388b287c5..e7d43f1ec 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -390,6 +390,11 @@ pub trait EffectHost: Sync { fn param_at(&self, _effect_index: usize, _id: &str, _layer_time: f64) -> Option { None } + /// Parameter `id` of effect `effect_index` has one value at every time (no keyframes, no + /// expression), so sampling it over time can be skipped. `false` when unknown. + fn param_is_static(&self, _effect_index: usize, _id: &str) -> bool { + false + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 15544eb81..ea664ba41 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -1006,7 +1006,8 @@ fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> /// other times, the current value for every step. fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec { let now = ctx.params.f(id); - let host = ctx.env.host; + // A rate without keyframes or an expression is the same at every step. + let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id)); (0..steps_at(ctx.time)) .map(|i| { let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64()); diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..14249900f 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -60,7 +60,13 @@ impl<'a> EvalCtx<'a> { let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); return Value::Vec3([self.value(layer, px).as_f64(), y, z]); } - let lt = layer.layer_time(self.time); + self.value_at_layer_time(layer, prop, layer.layer_time(self.time)) + } + /// [`Self::value`] with the keyframes read at layer time `lt` exactly, rather than at the + /// context time mapped through the layer's start and stretch (which rounds under a + /// fractional stretch: a Hold key at `lt` could read its previous value). An expression + /// still runs at the context time. + pub fn value_at_layer_time(&self, layer: &Layer, prop: &Property, lt: Tick) -> Value { let v = prop.value_at(lt); if prop.has_expression() && let Some(h) = self.expr diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5597de93..787fe1245 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -344,24 +344,16 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn param_at(&self, effect_index: usize, id: &str, layer_time: f64) -> Option { - // Only the one property: a simulation samples it at every step. - let mut g = self.layer.effects()?.groups().nth(effect_index)?; - let mut path = id.split('/').peekable(); - while let Some(m) = path.next() { - if path.peek().is_none() { - let p = g.children.iter().find_map(|c| match c { - effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p), - _ => None, - })?; - let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time))); - return Some(ctx.value(self.layer, p)); - } - g = g.children.iter().find_map(|c| match c { - effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg), - _ => None, - })?; - } - None + // Only the one property: a simulation samples it at every step. Its keyframes are read + // at the requested layer time exactly (mapping it to comp time and back rounds under a + // fractional stretch, so a Hold key there could read its previous value). + let p = effect_param(self.layer, effect_index, id)?; + let lt = Tick::from_seconds_f64(layer_time); + Some(self.ctx.at(self.layer.comp_time(lt)).value_at_layer_time(self.layer, p, lt)) + } + + fn param_is_static(&self, effect_index: usize, id: &str) -> bool { + effect_param(self.layer, effect_index, id).is_some_and(|p| p.keys.is_empty() && !p.has_expression()) } fn comp_scene(&self) -> Option { @@ -386,6 +378,26 @@ impl EffectHost for FxHost<'_, '_, '_> { } } +/// Parameter `id` (a flattened id: match ids joined by `/`) of effect `effect_index` in +/// `layer`'s stack. +fn effect_param<'a>(layer: &'a Layer, effect_index: usize, id: &str) -> Option<&'a effectcraft_project::Property> { + let mut g = layer.effects()?.groups().nth(effect_index)?; + let mut path = id.split('/').peekable(); + while let Some(m) = path.next() { + if path.peek().is_none() { + return g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Prop(p) if p.match_id == m => Some(p), + _ => None, + }); + } + g = g.children.iter().find_map(|c| match c { + effectcraft_project::Node::Group(sg) if sg.match_id == m => Some(sg), + _ => None, + })?; + } + None +} + /// The Essential Properties overrides of a precomp layer, evaluated at the context time. pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec { use effectcraft_project::essential; From aa300d7f2e6b8a7dfbb170bd5cac43469c0b27c6 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:59:03 -0400 Subject: [PATCH 0065/1386] Render: CC Particle World layers with expressions aren't layer cached (#1373) The simulation checks its checkpoints against the Birth Rate history, but a cached layer is returned before the effect runs. The layer key sees the layer's keyframes and its current values, not what an expression returned at earlier times: a Birth Rate expression reading another layer could change the history while the current rate, 0, stayed the same. effects::reads_param_history names CC Particle World. The renderer already declines to cache layers whose time effects (ec.time.*) meet expressions. It now treats Particle World the same way, in the layer key, the input key (only for the effects that input runs) and the dependency walk. --- crates/effects/src/lib.rs | 7 ++++++ crates/render/src/cache.rs | 29 ++++++++++++++++++------- crates/render/src/cache/dependencies.rs | 6 ++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e7d43f1ec..645ae20bc 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1248,6 +1248,13 @@ pub fn is_time_dependent(id: &str) -> bool { id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } +/// Does effect `id` read its own parameters at earlier times (CC Particle World samples its +/// Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an +/// expression returned then. +pub fn reads_param_history(id: &str) -> bool { + id == "ec.sim.ccparticleworld" +} + /// Run an effect on a buffer. pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..47b2c0ebd 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -484,10 +484,11 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - // Time effects see property values at other times. Keyframes are part of the hashed - // structure (and the layer time is folded in), but expressions may read other layers at - // those times, which the key cannot see: such layers are not cached. - if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { + // Time effects (and CC Particle World's Birth Rate history) see property values at other + // times. Keyframes are part of the hashed structure (and the layer time is folded in), but + // expressions may read other layers at those times, which the key cannot see: such layers + // are not cached. + if history_unkeyable(layer, None) { return None; } key_with(ctx, layer, scale, draft, blur, false) @@ -497,7 +498,7 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { - if layer.switches.adjustment { + if layer.switches.adjustment || history_unkeyable(layer, Some(effects)) { return None; } let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; @@ -506,14 +507,26 @@ pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo Some(h.finish()) } -/// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)? -fn reads_other_times(layer: &Layer) -> bool { +/// Does the layer run (among its first `effects` effects, all when `None`) an effect that reads +/// the layer at other times (Echo, Timewarp…) or its own parameters' history (CC Particle +/// World's Birth Rate, #1373)? +fn reads_other_times(layer: &Layer, effects: Option) -> bool { layer.switches.effects && layer.effects().is_some_and(|fx| { - fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time."))) + fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| { + g.enabled + && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } + if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect)) + }) }) } +/// The layer reads values at other times (see [`reads_other_times`]) and has expressions +/// (outside Transform), whose results at those times the key can't see. +fn history_unkeyable(layer: &Layer, effects: Option) -> bool { + reads_other_times(layer, effects) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index eaccd0582..dfb80479e 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -7,7 +7,7 @@ use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, P use std::hash::Hash; pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option) -> Option<()> { - let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) }; + let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer, None) }; deps.effects(ctx, layer, effects) } @@ -21,7 +21,7 @@ struct Dependencies<'a> { impl Dependencies<'_> { fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option) -> Option<()> { let previous = self.other_times; - self.other_times |= super::reads_other_times(layer); + self.other_times |= super::reads_other_times(layer, None); if layer.switches.effects && let Some(fx) = layer.effects() { @@ -69,7 +69,7 @@ impl Dependencies<'_> { return None; } self.remaining = self.remaining.checked_sub(1)?; - if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) { + if (self.other_times || super::reads_other_times(layer, None)) && super::has_expression(&layer.props) { return None; } self.path.push(id); From f22ce3d8390763a0e449a8f0b3f82891c49448f6 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:59:03 -0400 Subject: [PATCH 0066/1386] Render: tests for a Hold Birth Rate cut under fractional stretch and an expression's history (#1373) A Hold cut at 0.5 s reads 3 just before and 0 at 0.5 s under 33.3 %, 133.33 % and 200 % stretch, and the frame matches the unstretched one. A Birth Rate expression reading a Controller layer's Opacity before 0.5 s: changing the Controller changes the 0.9 s frame, and the cached render matches the uncached one. --- crates/render/src/tests.rs | 83 +++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index add2ec634..43c5a8cc9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -315,6 +315,11 @@ fn particle_world_follows_the_comp_camera() { /// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a /// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %. fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) { + burst_scene_keys(cut, start, stretch, false) +} + +/// [`burst_scene`]; `hold`: Hold keys, 3 until `cut` and 0 from `cut` exactly. +fn burst_scene_keys(cut: Option, start: f64, stretch: f64, hold: bool) -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))]; @@ -327,7 +332,7 @@ fn burst_scene(cut: Option, start: f64, stretch: f64) -> (Project, ItemId) let fx = fx.unwrap(); let rate = fx.prop_mut("birthRate").unwrap(); let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); - rate.keys = vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)]; + rate.keys = if hold { vec![key(0.0, 3.0).hold(), key(cut, 0.0).hold()] } else { vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)] }; } l.start_time = Tick::from_seconds_f64(start); l.in_point = l.start_time; @@ -375,6 +380,82 @@ fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() { assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %"); } +/// The Birth Rate history is read at exact layer times: under a fractional stretch, mapping a +/// layer time to comp time and back rounds down a tick, so a Hold cut at 0.5 s read its +/// previous value at the step starting there (six extra births). +#[test] +fn particle_world_reads_a_hold_cut_at_its_exact_layer_time_under_any_stretch() { + use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer}; + let plain = { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, 100.0, true); + render_cached(&p, cid, Tick::from_seconds_f64(0.9), None) + }; + assert!(light(&plain) > 1.0, "the burst is drawn"); + for stretch in [33.3, 133.33, 200.0] { + let (p, cid) = burst_scene_keys(Some(0.5), 0.0, stretch, true); + let c = p.comp(cid).unwrap(); + let l = &c.layers[0]; + let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO); + let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let host = FxHost { r: &renderer, ctx: &ctx, layer: l, origin: [0.0; 2], index: Default::default(), original: None }; + let rate = |t: f64| host.param_at(0, "birthRate", t).unwrap().as_f64(); + assert_eq!((rate(0.5 - 1.0 / 60.0), rate(0.5)), (3.0, 0.0), "stretch {stretch} %"); + assert!(!host.param_is_static(0, "birthRate")); + let t = l.comp_time(Tick::from_seconds_f64(0.9)); + assert_same(&render_cached(&p, cid, t, None), &plain, &format!("stretch {stretch} %")); + } +} + +/// An expression that reads a Controller layer's Opacity (÷ 25) before layer time 0.5 s and is +/// 0 after (a stand-in for the expression engine). +struct ControllerRate; + +impl crate::eval::ExprHost for ControllerRate { + fn eval(&self, ctx: &crate::eval::EvalCtx, layer: &effectcraft_project::Layer, _: &effectcraft_project::Property, _: &Value) -> Result { + if layer.layer_time(ctx.time).seconds() >= 0.5 { + return Ok(Value::Scalar(0.0)); + } + let c = ctx.comp.layers.iter().find(|l| l.name == "Controller").ok_or("no controller")?; + let opacity = c.props.prop("transform/opacity").ok_or("no opacity")?; + Ok(Value::Scalar(ctx.value(c, opacity).as_f64() / 25.0)) + } +} + +/// A Birth Rate expression reading another layer: changing that layer changes the history but +/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees. +/// CC Particle World layers with expressions therefore aren't layer cached. +#[test] +fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() { + let scene = |opacity: f64| { + let (mut p, cid) = burst_scene(None, 0.0, 100.0); + let comp = p.comp(cid).unwrap().clone(); + let mut ctl = solid(&mut p, &comp, [0.0, 0.0, 0.0], 10, 10); + ctl.name = "Controller".into(); + ctl.switches.video = false; + ctl.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity); + let c = p.comp_mut(cid).unwrap(); + let fx = c.layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "controller".into(), enabled: true }); + c.layers.push(ctl); + (p, cid) + }; + let render = |p: &Project, cid: ItemId, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default()); + r.expr = Some(&ControllerRate); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(0.9)) + }; + let ((many, cid), (few, _)) = (scene(75.0), scene(25.0)); + let cache = crate::LayerCache::default(); + let a = render(&many, cid, Some(&cache)); + let b = render(&few, cid, Some(&cache)); + assert_same(&b, &render(&few, cid, None), "cached render after the controller changed"); + assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { From d04f88750e8f2735825b6e5010a0a90c6d700786 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:59:03 -0400 Subject: [PATCH 0067/1386] GPU: the keyed Birth Rate test tells birth timing from birth count and checks the GPU ran (#1373) The two histories now bear the same 180 particles by 0.9 s at different steps (3/s from 0 to 0.5 s, and from 0.25 to 0.75 s). So a checkpoint check by birth count alone fails the test, not only no check. A test-only dispatch counter asserts each GPU state really came from the GPU rather than the CPU fallback. --- crates/gpu/src/particles.rs | 6 ++++++ crates/gpu/src/tests_particles.rs | 14 ++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 837746547..7c5136d51 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -121,6 +121,10 @@ fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { out } +/// Simulations dispatched on the GPU (tests check the GPU path really ran). +#[cfg(test)] +pub(crate) static DISPATCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). /// /// With deferred readbacks (a browser worker) the state's readback is keyed by the request @@ -202,6 +206,8 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option Vec { /// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU /// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one -/// history is not resumed for another (the burst's start moved, so the same ids are born at -/// other steps; same system key). +/// history is not resumed for another with the same system key, even when both have born as +/// many particles (180 by 0.9 s here) at other steps. #[test] fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() { let Some(g) = gpu() else { return }; - fn long(t: f64) -> f64 { - if t < 0.5 { 4.0 } else { 0.0 } + fn early(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } } fn late(t: f64) -> f64 { - if (0.2..0.5).contains(&t) { 4.0 } else { 0.0 } + if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 } } let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]); - for (rate, times) in [(long as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { + for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); for t in times { let cpu = keyed_state(&p, t, &cpu_host); + let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed); let gpu = keyed_state(&p, t, &gpu_host); + assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "t {t}: simulated on the GPU"); agree(&format!("keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); } } From ba38397da411a8667b769eee2bb16d7040128fc4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:59:04 -0400 Subject: [PATCH 0068/1386] Docs: architecture.md notes exact-time sampling and the layer cache for Particle World (#1373) --- docs/architecture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1e0a2e804..70722339d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -371,7 +371,10 @@ GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU -checkpoint's births). +checkpoint's births). The rate's keyframes are read at the step's exact layer time +(`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional +stretch), a rate without keyframes or an expression isn't sampled, and a Particle World layer with +expressions isn't layer cached (its key can't see what an expression returned at earlier times). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an adjustment-layer comp. From f4ab728c0abc9cb421fe05db0ae1cb7c279cfec8 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:36 -0400 Subject: [PATCH 0069/1386] Particles: CC Particle Systems II and CC Mr. Mercury bear at the Birth Rate of each moment (#1373) Both simulate from layer time 0, as CC Particle World does, and used the Birth Rate of the rendered frame for every step. A rate keyed down to 0 emptied them the frame it got there, although their particles (and Mercury's blobs) were within their Longevity. CC Particle World's simulation path becomes Stepped: Birth Rate sampled at every step (rate_history), a cache key without it, and checkpoints resumed only with the same rate history. Particle World, Particle Systems II (60 particles a second per unit, CPU or GPU) and Mr. Mercury (10 blobs a second per unit, CPU) now all use it. A constant Birth Rate gives exactly the particles it did. The constant-rate step, key and simulation (Phys::step, sim_key, simulate) are now only the tests' reference. Mr. Mercury's physics move into mercury_phys. --- crates/effects/src/sim.rs | 89 ++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index ea664ba41..8d7849019 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul}; +use crate::util::{SimCache, hash1, params_key_except, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -869,6 +869,8 @@ impl Phys { Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id } } + /// One step at the constant [`Phys::rate`] (the tests' reference). + #[cfg(test)] pub(crate) fn step(&self, st: &mut PState, step: u64) { self.step_at_rate(st, step, self.rate); } @@ -1029,12 +1031,39 @@ fn simulate_rates(cache: &SimCache, key: u64, phys: &Phys, rates: &[f64] cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist)) } +/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own +/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every +/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under +/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it. +struct Stepped<'a> { + cache: &'a SimCache, + salt: u64, + skip: &'a [&'a str], + k: f64, + gpu: bool, +} + +impl Stepped<'_> { + fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { + let rates = rate_history(ctx, "birthRate", self.k); + let mut skip = self.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip); + let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None }; + gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates)) + } +} + +/// CC Particle World. +const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true }; +/// CC Particle Systems II. +const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true }; +/// CC Mr. Mercury (its blobs are simulated on the CPU). +const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false }; + /// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time. fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { - let rates = rate_history(ctx, "birthRate", PW_RATE); - // Every parameter but Birth Rate (its history is checked through `PState::hist`). - let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); - accelerated(ctx, key, phys, Some(&rates)).unwrap_or_else(|| simulate_rates(&PW_CACHE, key, phys, &rates)) + PW.state(ctx, phys) } /// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, @@ -1043,23 +1072,22 @@ fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc { pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { let st = match id { "ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)), - "ec.sim.ccparticlesystems2" => { - let (phys, key) = (ps2_phys(ctx), sim_key(ctx, 2)); - accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)) - } + "ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)), _ => return None, }; Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) } -/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's -/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart -/// the simulation). +/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests' +/// reference): every parameter but CC Particle World's Effect Camera. +#[cfg(test)] fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 { params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA]) } -/// Run a particle system to layer time `t` through `cache`. +/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the +/// tests' reference). +#[cfg(test)] pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) } @@ -1265,6 +1293,9 @@ fn particle_sprites(sprites: Vec, mode: u32) -> SpritePlan { static PS2_CACHE: SimCache = SimCache::new(6); +/// Particles per second per unit of CC Particle Systems II's Birth Rate. +const PS2_RATE: f64 = 60.0; + /// CC Particle Systems II's physics. fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; @@ -1273,7 +1304,7 @@ fn ps2_phys(ctx: &EffectCtx) -> Phys { let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); Phys { - rate: pr.f("birthRate").max(0.0) * 60.0, + rate: pr.f("birthRate").max(0.0) * PS2_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0], @@ -1300,9 +1331,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); - let phys = ps2_phys(ctx); - let key = sim_key(ctx, 2); - let st = accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); + let st = PS2.state(ctx, &ps2_phys(ctx)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -1327,6 +1356,9 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan { static MERCURY_CACHE: SimCache = SimCache::new(6); +/// Blobs per second per unit of CC Mr. Mercury's Birth Rate. +const MERCURY_RATE: f64 = 10.0; + /// A CC Mr. Mercury blob (buffer px). #[derive(Clone, Copy, Debug)] pub struct Blob { @@ -1335,15 +1367,14 @@ pub struct Blob { pub r: f32, } -/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). -pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { +/// CC Mr. Mercury's physics. +fn mercury_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let lh = ctx.layer_size[1] as f32; - let unit = lh.max(1.0); + let unit = (ctx.layer_size[1] as f32).max(1.0); let pos = pr.v2("producer"); let dir = (pr.f("direction") as f32).to_radians(); - let phys = Phys { - rate: pr.f("birthRate").max(0.0) * 10.0, + Phys { + rate: pr.f("birthRate").max(0.0) * MERCURY_RATE, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0], @@ -1357,8 +1388,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { seed: ctx.seed.wrapping_mul(0xc2b2ae35), max: 4_000, flat: true, - }; - let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys); + } +} + +/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`). +pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec { + let pr = ctx.params; + let unit = (ctx.layer_size[1] as f32).max(1.0); + let phys = mercury_phys(ctx); + let st = MERCURY.state(ctx, &phys); let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32); let s = b.scale as f32; st.parts @@ -2185,4 +2223,5 @@ mod tests { assert_eq!(st.next_id as usize, births.len()); assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); } + } From 16cc290a476f0674f90f02d0fc6fb160d102152e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:36 -0400 Subject: [PATCH 0070/1386] Particles: tests for CC Particle Systems II and CC Mr. Mercury with a Birth Rate over time (#1373) For both: a burst lives on after Birth Rate drops to 0, and is drawn (particles, blobs) where a constant 0 draws nothing. Its particles move and age, and are gone after their Longevity. Seeking matches a simulation from 0, and moving an earlier key with the same current rate and cache key isn't served from stale checkpoints. A constant rate gives exactly the constant-rate simulation's particles, with or without a host. --- crates/effects/src/sim.rs | 106 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 8d7849019..ab2884451 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -2224,4 +2224,110 @@ mod tests { assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0"); } + // ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time + + /// A stepped particle effect: its id, physics and simulation. + struct System { + id: &'static str, + phys: fn(&EffectCtx) -> Phys, + stepped: Stepped<'static>, + } + + const SYSTEMS: [System; 2] = + [System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }]; + + /// `id`'s parameters with Birth Rate `now` and Longevity 1 s. + fn sys_params(id: &str, now: f64) -> crate::Params { + let spec = crate::find(id).unwrap(); + let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() }; + for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] { + assert!(p.values.contains_key(k), "{id}: no {k}"); + p.values.insert(k.to_string(), v); + } + p + } + + /// The context at layer time `t` (`seed` keeps each test's simulations apart). + fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> { + let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() }; + EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env } + } + + /// The live particles (id, position, age) of `sys` at `t` through its simulation. + fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect() + } + + /// The same, simulated from layer time 0 with a cache of its own (the reference). + fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, Some(host), seed); + let mut skip = sys.stepped.skip.to_vec(); + skip.push("birthRate"); + let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip); + let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k)); + st.parts.iter().map(|q| (q.id, q.p, q.age)).collect() + } + + /// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs. + fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize { + let p = sys_params(sys.id, now); + let ctx = sys_ctx(&p, t, host, seed); + match sys.id { + "ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(), + id => particle_state(id, &ctx).unwrap().len(), + } + } + + /// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate + /// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity. + #[test] + fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() { + let host = Rate { index: 1, rate: burst }; + for sys in &SYSTEMS { + let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len(); + assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id); + let late = sys_at(sys, 201, 0.0, 0.9, Some(&host)); + assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len()); + assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id); + assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id); + let early = sys_at(sys, 201, 0.0, 0.7, Some(&host)); + let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap(); + assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]); + assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id); + } + } + + /// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same + /// cache key) isn't served from the old history's checkpoints. + #[test] + fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() { + let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); + for sys in &SYSTEMS { + for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { + let now = burst(t); + assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id); + } + let b = sys_at(sys, 202, 0.0, 0.9, Some(&short)); + assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id); + assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id); + } + } + + /// A constant Birth Rate gives exactly the particles the constant-rate simulation did. + #[test] + fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() { + let host = Rate { index: 0, rate: |_| 2.0 }; + for sys in &SYSTEMS { + let p = sys_params(sys.id, 2.0); + let ctx = sys_ctx(&p, 1.3, None, 203); + let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx)); + let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect(); + assert!(!before.is_empty()); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id); + assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id); + } + } } From 6fcfc355e3e9a40ace32addf7a350d2213c20eab Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:37 -0400 Subject: [PATCH 0071/1386] Render: CC Particle Systems II and CC Mr. Mercury layers with expressions aren't layer cached (#1373) They now read their Birth Rate at earlier times too, so effects::reads_param_history names them along with CC Particle World. A layer key can't see what an expression returned then. --- crates/effects/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 645ae20bc..c4061f680 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1248,11 +1248,11 @@ pub fn is_time_dependent(id: &str) -> bool { id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } -/// Does effect `id` read its own parameters at earlier times (CC Particle World samples its -/// Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an +/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample +/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an /// expression returned then. pub fn reads_param_history(id: &str) -> bool { - id == "ec.sim.ccparticleworld" + matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury") } /// Run an effect on a buffer. From 6df1fc17b9390237b3062b94eab9df4616ac6faa Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:37 -0400 Subject: [PATCH 0072/1386] Render: tests for CC Particle Systems II and CC Mr. Mercury bursts and their layer cache (#1373) Through the renderer, a keyed burst draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate of 0 throughout), and draws as nothing born once Longevity has run out. A Birth Rate expression on any of the three stepped particle systems keeps the layer out of the layer cache; without one it is cached. --- crates/render/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 43c5a8cc9..bd29deb23 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -456,6 +456,64 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a)); } +/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems +/// II, CC Mr. Mercury): Birth Rate `rate`, +/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s. +fn system_scene(id: &str, rate: Option) -> (Project, ItemId) { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); + let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))]; + add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals); + if rate.is_none() { + let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v)); + fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)]; + } + p.comp_mut(cid).unwrap().layers = vec![l]; + (p, cid) +} + +/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst +/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate +/// of 0 throughout), and draws as nothing born once Longevity has run out. +#[test] +fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() { + let at = Tick::from_seconds_f64; + for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (keyed, cid) = system_scene(id, None); + let (constant, _) = system_scene(id, Some(3.0)); + let (none, _) = system_scene(id, Some(0.0)); + assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst")); + let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None)); + assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z)); + assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity")); + } +} + +/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the +/// layer cache (its key can't see what the expression returned at earlier times); without one +/// the layer is cached. +#[test] +fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() { + for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] { + let (mut p, cid) = system_scene(id, Some(2.0)); + let key = |p: &Project| { + let c = p.comp(cid).unwrap(); + crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false) + }; + assert!(key(&p).is_some(), "{id}: cached without an expression"); + let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c { + effectcraft_project::Node::Group(g) => Some(g), + _ => None, + }); + fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true }); + assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression"); + } +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { From 8d00db7d9002254825135befa4a94a0cc18d521a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:37 -0400 Subject: [PATCH 0073/1386] GPU: test CC Particle Systems II against the CPU with a keyed Birth Rate (#1373) The keyed Birth Rate comparison is shared (keyed_agree) and now runs on CC Particle Systems II too. The same two histories bear 90 particles by 0.9 s at different steps: the GPU's particles match the CPU's, and each state really came from the GPU. --- crates/gpu/src/tests_particles.rs | 56 ++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs index 96ffd434e..e11dd6585 100644 --- a/crates/gpu/src/tests_particles.rs +++ b/crates/gpu/src/tests_particles.rs @@ -113,11 +113,36 @@ impl EffectHost for Keyed { } } -/// CC Particle World's live particles at layer time `t` through `host`. -fn keyed_state(p: &Params, t: f64, host: &Keyed) -> Vec { +/// Effect `id`'s live particles at layer time `t` through `host`. +fn keyed_state(id: &str, p: &Params, t: f64, host: &Keyed) -> Vec { let env = EffectEnv { host: Some(host), ..Default::default() }; let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; - effectcraft_effects::particle_state("ec.sim.ccparticleworld", &ctx).unwrap() + effectcraft_effects::particle_state(id, &ctx).unwrap() +} + +/// 3 a second from 0 to 0.5 s. +fn early(t: f64) -> f64 { + if t < 0.5 { 3.0 } else { 0.0 } +} + +/// 3 a second from 0.25 to 0.75 s: as many births by 0.9 s as [`early`], at other steps. +fn late(t: f64) -> f64 { + if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 } +} + +/// Effect `id` (parameters `p`) under the [`early`] then the [`late`] Birth Rate history: the +/// GPU's particles match the CPU's, and each really came from the GPU. +fn keyed_agree(id: &str, p: &Params, g: &'static Gpu) { + for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { + let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); + for t in times { + let cpu = keyed_state(id, p, t, &cpu_host); + let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed); + let gpu = keyed_state(id, p, t, &gpu_host); + assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "{id} t {t}: simulated on the GPU"); + agree(&format!("{id} keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); + } + } } /// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU @@ -127,23 +152,16 @@ fn keyed_state(p: &Params, t: f64, host: &Keyed) -> Vec { #[test] fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() { let Some(g) = gpu() else { return }; - fn early(t: f64) -> f64 { - if t < 0.5 { 3.0 } else { 0.0 } - } - fn late(t: f64) -> f64 { - if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 } - } let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]); - for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] { - let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate }); - for t in times { - let cpu = keyed_state(&p, t, &cpu_host); - let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed); - let gpu = keyed_state(&p, t, &gpu_host); - assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "t {t}: simulated on the GPU"); - agree(&format!("keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1); - } - } + keyed_agree("ec.sim.ccparticleworld", &p, g); +} + +/// The same for CC Particle Systems II (90 births by 0.9 s in both histories). +#[test] +fn particle_systems_ii_with_a_keyed_birth_rate_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let p = params("ec.sim.ccparticlesystems2", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("randomSeed", n(22.0))]); + keyed_agree("ec.sim.ccparticlesystems2", &p, g); } #[test] From 082bded546c7959ee6a57cab39599cb4d0c1e5e7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 22:35:37 -0400 Subject: [PATCH 0074/1386] Docs: architecture.md names the three particle systems that sample their Birth Rate (#1373) --- docs/architecture.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 70722339d..30e2d6045 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,12 +368,12 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). -CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a -keyed rate only decides the births of its own time; the rate is left out of the simulation key, -and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every +step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; +the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU checkpoint's births). The rate's keyframes are read at the step's exact layer time (`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional -stretch), a rate without keyframes or an expression isn't sampled, and a Particle World layer with +stretch), a rate without keyframes or an expression isn't sampled, and a layer of one of them with expressions isn't layer cached (its key can't see what an expression returned at earlier times). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an From 59b144439ef5259f235b854ff49da041d5be5c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:10 +0000 Subject: [PATCH 0075/1386] #1436: the Gradient Editor previews live and leaves the window undimmed The Gradient Editor edited a draft and set the property only on OK, in a centred dialog over a dimmed, greyed-out window, so the canvas could not show the gradient while it was edited. In After Effects the dialog sits at the lower right, the window stays at full brightness, and the canvas updates live (Preview checkbox). - Preview (on by default): each edit is applied with `prop.set` and one merged undo step; OK keeps it, Cancel and Escape roll back to a checkpoint of the project and undo history taken on open (the Layer Styles dialog's mechanism, now shared). Preview off sets the property on OK only, as before; switching it off takes back what it applied. - The editor opens at the lower right (`Placement` for modal dialogs), without the dark backdrop, and the window behind is no longer faded (`Dialog::leaves_window_undimmed`); clicks still don't reach it. - Other dialogs are unchanged. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 13 ++- crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/dialogs.rs | 34 ++++++- crates/ui-egui/src/panels/gradient_editor.rs | 88 +++++++++++++++--- .../ui-egui/src/panels/layer_styles_dialog.rs | 26 ++++-- crates/ui-egui/tests/ui_gradient_editor.rs | 93 ++++++++++++++++++- 6 files changed, 235 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..edf4a96c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,14 @@ pub enum Dialog { DeleteTemplate, } +impl Dialog { + /// The dialog edits what is behind it live (Preview), so the window stays at full brightness + /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). + pub fn leaves_window_undimmed(self) -> bool { + matches!(self, Dialog::GradientEditor) + } +} + /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -1680,8 +1688,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if self.dialog.is_some() { + if let Some(d) = self.dialog { ui.disable(); + if d.leaves_window_undimmed() { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..b104f0b9a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1785,6 +1785,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -1902,6 +1905,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only crate::panels::forms::close_info(app); continue; } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + continue; + } // Escape in Settings is Cancel: restore the settings from when it opened. if app.dialog == Some(crate::Dialog::Settings) && let Some(p) = app.dialog_state.prefs_snapshot.take() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f0e44368e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -188,16 +213,21 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let size = size.min(modal_space(ctx)); let id = egui::Id::new(("modal", title)); // Dragging the title row moves the dialog off what it edits. The offset is kept within the // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bb6923025..bd20c28cd 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +63,12 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +231,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -230,6 +244,11 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); + // The preview already set the property, in one merged undo step: OK keeps it. + if d.applied.is_some() { + app.session.history.merge_key = None; + return; + } let g = d.gradient(); let Some(tg) = d.target else { return }; if g == d.baseline { @@ -241,6 +260,49 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + restore(app, cp); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => app.dialog_state.gradient.applied = Some(g), + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + restore(app, cp); + app.dialog_state.gradient.applied = None; + } +} + fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -252,7 +314,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -410,6 +474,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -421,12 +489,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..440784b7e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,8 +23,9 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { +pub(crate) struct Checkpoint { project: std::sync::Arc, history: effectcraft_engine::history::History, } @@ -35,7 +36,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -257,14 +258,21 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_state(); - if changed { - app.session.bump(); - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } + +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_state(); + if changed { + app.session.bump(); + } + app.session.history.merge_key = None; +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index fa8c7b73f..7d8f46281 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,83 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] @@ -491,3 +571,12 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } + +/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while +/// it is open; other dialogs still are. +#[test] +fn only_dialogs_that_preview_leave_the_window_undimmed() { + assert!(Dialog::GradientEditor.leaves_window_undimmed()); + assert!(!Dialog::SolidSettings.leaves_window_undimmed()); + assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); +} From 25a752baa020faecdd4617ed955d5e5672b03031 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:04:30 +0000 Subject: [PATCH 0076/1386] #1436: Solid, Camera, Light and Composition Settings preview live The Gradient Editor's Preview now covers the other settings dialogs that edit something that exists: Solid Settings on a solid layer, Camera Settings, Light Settings and Composition Settings. A change of the draft reaches the project at once, so the canvas shows it while it is made. - One shared mechanism (`panels::live_preview`): each step goes back to a checkpoint of the project and undo history taken when the dialog opened, then runs the dialog's command once. So the project is always "as it opened, plus one command": OK leaves one undo step, Cancel and Escape restore the checkpoint, Preview off applies nothing until OK, and switching it off takes back what was applied. - A previewing dialog opens at the lower right, without the dark backdrop, and the window behind it is no longer greyed out; it can still be dragged by its title. The same dialogs creating something new (New Solid, New Camera, New Light, New Composition) are unchanged and have no Preview. - `Placement` is public, and `EffectcraftApp::dialog_state` gives tests and automation the drafts. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 26 +- crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/comp_settings.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 55 +++- crates/ui-egui/src/panels/dialogs_3d.rs | 159 +++++++---- crates/ui-egui/src/panels/gradient_editor.rs | 3 +- crates/ui-egui/src/panels/live_preview.rs | 117 ++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_dialog_preview.rs | 268 +++++++++++++++++++ crates/ui-egui/tests/ui_gradient_editor.rs | 9 - crates/ui-egui/tests/ui_project_rows.rs | 5 +- 11 files changed, 568 insertions(+), 82 deletions(-) create mode 100644 crates/ui-egui/src/panels/live_preview.rs create mode 100644 crates/ui-egui/tests/ui_dialog_preview.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index edf4a96c3..592be9255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,14 +101,6 @@ pub enum Dialog { DeleteTemplate, } -impl Dialog { - /// The dialog edits what is behind it live (Preview), so the window stays at full brightness - /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). - pub fn leaves_window_undimmed(self) -> bool { - matches!(self, Dialog::GradientEditor) - } -} - /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -578,6 +570,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Feed this frame's share of the queued synthetic input (control channel `ui.click`, /// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves /// → release as a real gesture. @@ -1688,9 +1692,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if let Some(d) = self.dialog { + if self.dialog.is_some() { ui.disable(); - if d.leaves_window_undimmed() { + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { ui.set_opacity(1.0); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b104f0b9a..5d2479bb2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1800,6 +1800,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } return; @@ -1916,6 +1918,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only app.session.prefs = p; app.session.prefs_changed(); } + crate::panels::live_preview::cancel(app); app.dialog = None; continue; } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..005b2bce7 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -248,6 +248,7 @@ pub fn show( auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, ) -> Option { let mut out = None; ui.horizontal(|ui| { @@ -470,6 +471,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f0e44368e..3e262bd89 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -65,6 +65,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -75,6 +77,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -107,6 +110,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -121,6 +125,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -136,6 +141,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -179,7 +185,7 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, /// Where a modal dialog opens and whether it dims the window behind it. The default is the middle /// of the window over a dimmed backdrop. #[derive(Clone, Copy, Debug)] -pub(crate) struct Placement { +pub struct Placement { /// The opening position as a fraction of the room the dialog can move in, from the middle /// `(0, 0)` to the bottom right `(1, 1)`. pub start: egui::Vec2, @@ -412,9 +418,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -432,11 +442,19 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app) { + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); @@ -446,7 +464,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -494,8 +520,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::new("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -581,16 +610,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..a8d24e9a3 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -87,8 +87,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -137,13 +143,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -155,6 +167,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -163,8 +178,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -217,46 +235,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -314,48 +352,65 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add_space(14.0); ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = KINDS[d.kind.min(3)]; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if kind != LightKind::Ambient { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = KINDS[d.kind.min(3)]; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if kind != LightKind::Ambient { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bd20c28cd..fbbfc01ab 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -315,8 +315,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } let mut answer: Option = None; // At the lower right, undimmed: the canvas it edits stays in view (After Effects). - let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; - super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..861f8eb9f --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,117 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => app.dialog_state.live.applied = Some((command, params)), + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + restore(app, cp); + } + app.dialog_state.live.applied = None; +} + +/// OK: the preview's result is the edit. Returns whether it already is (the caller then must not +/// run the command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp) -> bool { + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..65cb199d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; diff --git a/crates/ui-egui/tests/ui_dialog_preview.rs b/crates/ui-egui/tests/ui_dialog_preview.rs new file mode 100644 index 000000000..861548012 --- /dev/null +++ b/crates/ui-egui/tests/ui_dialog_preview.rs @@ -0,0 +1,268 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index 7d8f46281..3b5da558d 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -571,12 +571,3 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } - -/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while -/// it is open; other dialogs still are. -#[test] -fn only_dialogs_that_preview_leave_the_window_undimmed() { - assert!(Dialog::GradientEditor.leaves_window_undimmed()); - assert!(!Dialog::SolidSettings.leaves_window_undimmed()); - assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); -} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); From a1a1a36a417d7a5ee6298780be0692f241f79eef Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:38:38 +0400 Subject: [PATCH 0077/1386] i18n: context menus, tab titles, workspaces and column headers reach the catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text drawn outside the menu bar stayed English in every language: - i18n::command_label: the Timeline / Composition layer and property context menus now take the menu bar's row for the same command (… or ...), else the panel catalog's row; the layer menu's Effects submenu draws effect and category names through the effect catalog. - The dock's tab titles (and the dragged tab), the Composition, Effect Controls and Properties tabs, and the workspace bar and menus use the window.panel and window.workspace rows every menu catalog already has. - The Project panel's search hint, details hint and column headers, and the Timeline's column headers and Columns menu go through tr(). The Simplified and Traditional Chinese rows for "Type" read 文字 (text, the Type tool); the string is the Camera Settings field and now the Project column, so they read 类型 / 類型. dock_ui.rs joins the converted files. Languages without a row for a string keep the English, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit b335f1e2764929b78d7b678ec026f680b1a8129f) --- crates/ui-egui/src/dock_ui.rs | 10 +++-- crates/ui-egui/src/header.rs | 6 +-- crates/ui-egui/src/i18n.rs | 58 +++++++++++++++++++-------- crates/ui-egui/src/i18n/ui.rs | 5 ++- crates/ui-egui/src/panels/project.rs | 14 +++++-- crates/ui-egui/src/panels/timeline.rs | 12 +++--- crates/ui-egui/src/panels/viewers.rs | 2 +- 7 files changed, 71 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..ba9e9e6b9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -41,8 +41,8 @@ impl EffectcraftApp { } let Some(comp) = self.session.active_comp() else { return out }; if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { - out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); - out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name))); + out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name))); } out } @@ -206,7 +206,9 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| { + titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string()) + }; let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +572,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..acbd2fdcb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -259,7 +259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if mresp.clicked() { widgets::open_popup(ui, egui::Id::new("ws-pop")); } - let names: Vec = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect(); + let names: Vec = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect(); if let Some(i) = widgets::popup_menu( ui, egui::Id::new("ws-pop"), @@ -274,7 +274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Small Screen, Standard. let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { - let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text); + let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0)); if r.min.x < x + 120.0 { @@ -641,7 +641,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { for w in app.workspace_names() { - if ui.selectable_label(app.ui.workspace == w, &w).clicked() { + if ui.selectable_label(app.ui.workspace == w, crate::i18n::command_label("window.workspace", &w)).clicked() { app.set_workspace(&w); ui.close(); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dde..8bc348df7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -118,25 +118,37 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } - match language { - "ja" => lookup(JAPANESE, command, source), - "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), - "zh-hant" => lookup(TRADITIONAL_CHINESE, command, source), - "uk" => lookup(UKRAINIAN, command, source), - "es" => lookup(SPANISH, command, source), - "pt-br" => lookup(PORTUGUESE, command, source), - "ru" => lookup(RUSSIAN, command, source), - "fr" => lookup(FRENCH, command, source), - "de" => lookup(GERMAN, command, source), - "cs" => lookup(CZECH, command, source), - _ => source, - } + lookup(language, command, source).unwrap_or(source) } -/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is +/// A command's label drawn outside the menu bar (the Timeline's and Composition panel's layer and +/// property context menus, the dock's tab titles, the workspace bar), in the language the panels +/// draw in: the menu bar's row for (`command`, `source`), else the panel catalog's row for +/// `source`, else `source`. Those menus write `…` where the menu bar's sources have `...`, so +/// either spelling finds the menu bar's row. +pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str { + let language = ui::current(); + let dotted = source.strip_suffix('…').map(|stem| format!("{stem}...")); + lookup(language, command, source).or_else(|| lookup(language, command, dotted.as_deref()?)).unwrap_or_else(|| tr(source)) +} + +/// The row for (command, English label) in `language`'s menu catalog; `None` when the pair is /// absent, so computed labels and user names stay verbatim. -fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str { - catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated).unwrap_or(source) +fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { + let catalog = match language { + "ja" => JAPANESE, + "zh-hans" => SIMPLIFIED_CHINESE, + "zh-hant" => TRADITIONAL_CHINESE, + "uk" => UKRAINIAN, + "es" => SPANISH, + "pt-br" => PORTUGUESE, + "ru" => RUSSIAN, + "fr" => FRENCH, + "de" => GERMAN, + "cs" => CZECH, + _ => return None, + }; + catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) } pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String { @@ -6617,6 +6629,20 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + /// Labels drawn outside the menu bar take the menu bar's row for their command (with `…` for + /// `...`), else the panel catalog's row, else stay as they are. + #[test] + fn command_label_reads_the_menu_row_then_the_panel_row() { + set_current("zh-hans"); + assert_eq!(command_label("window.panel", "Project"), "项目"); + let (command, en, translated) = SIMPLIFIED_CHINESE.iter().find(|(c, en, _)| !c.is_empty() && en.ends_with("...")).expect("a menu row ending in ..."); + assert_eq!(command_label(command, &format!("{}…", en.trim_end_matches("..."))), *translated); + assert_eq!(command_label("no.such.command", "Cancel"), "取消"); + assert_eq!(command_label("window.panel", "Comp 1"), "Comp 1"); + set_current("en"); + assert_eq!(command_label("window.panel", "Project"), "Project"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..3a271aeb1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1055,7 +1055,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -1787,7 +1787,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -4209,6 +4209,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..f2d9c303a 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -425,13 +425,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } else { let hint = "Select an item to see its details"; - let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint); + let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint); app.auto.add("project.details.hint", r, hint); } // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); let mut q = app.ui.project_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search project", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t); app.ui.project_search = q; app.auto.add("project.search", sr, "Search"); // Column header: Name, Label, then the visible optional columns. @@ -463,7 +463,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -1094,7 +1100,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..065517d2a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1786,7 +1786,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Column headers scroll with the outline and never spill into the time ruler. let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y))); let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim); if vis.av { hic(Icon::Eye, cw.av + 10.0); hic(Icon::Speaker, cw.av + 28.0); @@ -1870,7 +1870,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (id, label) in COLUMNS { let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label }; let on = column_visible(&app.ui.timeline, id); - if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() { + if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() { actions.push(("timeline.column".into(), json!({"column": id, "visible": !on}))); ui.close(); } @@ -3796,7 +3796,7 @@ fn prop_menu( ) { let (l, uid) = (layer.id.0, prop.uid); let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { - let resp = ui.add(egui::Button::new(label).selected(selected)); + let resp = ui.add(egui::Button::new(crate::i18n::command_label(cmd, label)).selected(selected)); auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); if resp.clicked() { actions.push((cmd.into(), params)); @@ -3877,7 +3877,7 @@ fn layer_context_menu( pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { - if ui.button(label).clicked() { + if ui.button(crate::i18n::command_label(cmd, label)).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); actions.push((cmd.into(), params)); ui.close(); @@ -3959,10 +3959,10 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut widgets::menu_scroll(ui, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); - ui.menu_button(cat, |ui| { + ui.menu_button(crate::i18n::effects::category(cat), |ui| { widgets::menu_scroll(ui, |ui| { for e in fx { - item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); + item(ui, &crate::i18n::effects::name(e.id, e.name), "effect.apply", json!({"effect": e.id, "layers": layers})); } }); }); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 8d2351330..140dd6f8a 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) { /// The tab label of viewer `id` ("Composition Intro"). pub fn title(app: &EffectcraftApp, id: u32) -> Option { let c = comp_of(app, id)?; - Some(format!("Composition {}", app.session.project.item(c)?.name)) + Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name)) } From 5539bb4a95c0e879c46a4f4263a81f59d30d399a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:12 +0400 Subject: [PATCH 0078/1386] web: Match System follows ?lang= and the browser's languages Match System (the default) stayed English in the browser build, which has no system-locale probe (#229). The page now passes ?lang= from the address, then navigator.languages, and Match System takes the first of them with a catalog (an English tag counts), else English. A language chosen in Settings still wins; native builds are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit 73c064e3858068ed19fbfafbdd0b3c8cc229a42e) --- apps/effectcraft-web/Cargo.toml | 1 + apps/effectcraft-web/src/app.rs | 5 +++++ crates/ui-egui/src/i18n.rs | 35 ++++++++++++++++++++++++++++++--- docs/preferences.md | 2 +- 4 files changed, 39 insertions(+), 4 deletions(-) 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/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..a5fad30b1 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -184,6 +184,11 @@ 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").into_iter().chain(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"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8bc348df7..315e2ba34 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,35 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. The browser build follows the locales its page passes in ([`set_system_locales`]). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + first_supported(SYSTEM_LOCALES.get().map(Vec::as_slice).unwrap_or_default()) +} + +/// The browser's preferred languages, most preferred first (`?lang=` in the address, then +/// `navigator.languages`), for Match System in the web build. +static SYSTEM_LOCALES: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Tell Match System the system's preferred languages (the web build has no locale probe of its +/// own). Set before the first frame; only the first call counts. +pub fn set_system_locales(tags: Vec) { + let _ = SYSTEM_LOCALES.set(tags); +} + +/// The first of `tags` EffectCraft has a catalog for (English counts), else English: someone who +/// prefers Dutch, then Czech, gets Czech. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported(tags: &[String]) -> &'static str { + tags.iter() + .map(|t| (t, supported(Some(t)))) + .find(|(t, code)| *code != "en" || t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en"))) + .map_or("en", |(_, code)| code) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -6643,6 +6662,16 @@ mod tests { assert_eq!(command_label("window.panel", "Project"), "Project"); } + #[test] + fn match_system_takes_the_first_browser_language_with_a_catalog() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(super::first_supported(&tags(&["cs-CZ", "cs", "en"])), "cs"); + assert_eq!(super::first_supported(&tags(&["nl-NL", "cs"])), "cs"); + assert_eq!(super::first_supported(&tags(&["en-US", "cs"])), "en"); + assert_eq!(super::first_supported(&tags(&["nl-NL"])), "en"); + assert_eq!(super::first_supported(&tags(&[])), "en"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a1..59b0fc8a9 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From fb51427ec0080226d8282e9c799277a750990060 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:40:33 +0400 Subject: [PATCH 0079/1386] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Georgian (ქართული, code ka) to Settings > General > Language, with Match System for ka / ka-GE / ka_GE.UTF-8 locales: - i18n.rs: the menu catalog, the same 628 (command, English label) keys as the other languages, plus the Undo/Redo entries, the workspace and 3D view shortcut submenus, the Reveal labels on Windows and Linux and the macOS Services items. - i18n/ui.rs: GEORGIAN, every row of the Chinese panel catalogs (687), and GEORGIAN_PANELS, 195 rows for text the panels send through tr() that no catalog has yet (form dialogs, drop messages, screen-reader names, context menus, Project and Timeline column headers). The tests hold them to the same rules (unique, same placeholders, used by a converted panel) and keep them from shadowing a Chinese row; prefs_live.rs joins the converted files. - i18n/effects.rs: Georgian names for all 307 effects and 21 categories; the CC series and brand names stay in English. - Properties > Text Animation: the Add Animator and Presets buttons grow with their translated labels (English keeps 116 / 84 px). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 3 +- crates/engine/src/prefs.rs | 1 + crates/engine/src/tests_settings.rs | 6 + crates/ui-egui/src/i18n.rs | 739 +++++++++++++++++- crates/ui-egui/src/i18n/effects.rs | 366 ++++++++- crates/ui-egui/src/i18n/ui.rs | 986 +++++++++++++++++++++++- crates/ui-egui/src/native_menu.rs | 23 + crates/ui-egui/src/panels/properties.rs | 6 +- docs/gaps.md | 6 +- docs/localization-parity.md | 10 +- docs/preferences.md | 2 +- 11 files changed, 2130 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a579491a8..26786ee9a 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 @@ -131,6 +131,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/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3abf336b0..9bcea9444 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Français", "fr"), ("Deutsch", "de"), ("Čeština", "cs"), + ("ქართული", "ka"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886fc..baf914619 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -766,6 +766,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Georgian too. + assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + let mut ka = Session { config: Some(store.clone()), ..Default::default() }; + ka.load_settings(); + assert_eq!(ka.prefs.general.language, "ka"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 315e2ba34..ed5de67cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,7 +13,7 @@ use effectcraft_engine::menus::{MenuEntry, MenuNode}; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; +const CATALOG_CODES: [&str; 11] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs", "ka"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -64,6 +64,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Georgian. +pub(crate) fn georgian(app: &EffectcraftApp) -> bool { + language(app) == "ka" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build follows the locales its page passes in ([`set_system_locales`]). @@ -98,7 +103,8 @@ fn first_supported(tags: &[String]) -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`, +/// `ka-GE` → `ka`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -109,6 +115,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ka" => "ka", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -137,6 +144,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Explorer-ში ჩვენება", + "Reveal in File Manager" => return "ფაილების მენეჯერში ჩვენება", + _ => {} + } + } lookup(language, command, source).unwrap_or(source) } @@ -165,6 +179,7 @@ fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "ka" => GEORGIAN, _ => return None, }; catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) @@ -190,6 +205,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". + "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), _ => return shown, }; match e.command.as_str() { @@ -299,6 +316,17 @@ pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> Stri return format!("Atribuir atalho à visualização 3D “{name}”"); } } + "ka" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("მალსახმობის მინიჭება სამუშაო სივრცისთვის „{name}“"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("მალსახმობის მინიჭება 3D ხედისთვის „{name}“"); + } + } _ => {} } label(app, "", &shown).into() @@ -6641,6 +6669,641 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), ]; +/// Georgian (ka): the same (command, English label) keys as `JAPANESE`, one entry per fixed menu row. +/// Original translations written from the meaning of the English labels, sharing the terminology of +/// the other Craft apps' Georgian catalogs (შრე, საკვანძო კადრი, კომპოზიცია, ნიღაბი); commands are +/// verbal nouns (შენახვა, გახსნა) and Georgian has no capital letters. +const GEORGIAN: &[(&str, &str, &str)] = &[ + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "EffectCraft-ის შესახებ..."), + ("", "Settings...", "პარამეტრები..."), + ("app.settings", "General...", "ზოგადი..."), + ("app.settings", "Startup & Repair...", "გაშვება და აღდგენა..."), + ("app.settings", "Project...", "პროექტი..."), + ("app.settings", "Composition...", "კომპოზიცია..."), + ("app.settings", "Previews...", "წინასწარი ნახვა..."), + ("app.settings", "Appearance...", "იერსახე..."), + ("app.settings", "Grids & Guides...", "ბადე და დამხმარე ხაზები..."), + ("app.settings", "Labels...", "იარლიყები..."), + ("app.settings", "Type...", "ტექსტი..."), + ("app.settings", "Import...", "იმპორტი..."), + ("app.settings", "Export...", "ექსპორტი..."), + ("app.settings", "Audio...", "აუდიო..."), + ("app.settings", "Disk...", "დისკი..."), + ("app.settings", "Memory & CPU...", "მეხსიერება და CPU..."), + ("app.settings", "Video...", "ვიდეო..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "სკრიპტები და ექსპრესიები..."), + ("app.hide", "Hide EffectCraft", "EffectCraft-ის დამალვა"), + ("app.hideOthers", "Hide Others", "სხვების დამალვა"), + ("app.showAll", "Show All", "ყველას ჩვენება"), + ("app.quit", "Quit EffectCraft", "EffectCraft-იდან გასვლა"), + ("", "File", "ფაილი"), + ("", "New", "ახალი"), + ("file.newProject", "New Project", "ახალი პროექტი"), + ("file.newFromTemplate", "New Project from Template...", "ახალი პროექტი შაბლონიდან..."), + ("project.newFolder", "New Folder", "ახალი საქაღალდე"), + ("file.open", "Open Project...", "პროექტის გახსნა..."), + ("", "Open Recent", "ბოლოს გახსნილი ფაილების გახსნა"), + ("file.clearRecent", "Clear Recent Projects", "ბოლო პროექტების სიის გასუფთავება"), + ("file.close", "Close", "დახურვა"), + ("file.closeProject", "Close Project", "პროექტის დახურვა"), + ("file.save", "Save", "შენახვა"), + ("", "Save As", "შენახვა როგორც"), + ("file.saveAs", "Save As...", "შენახვა როგორც..."), + ("file.saveCopy", "Save a Copy...", "ასლის შენახვა..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "ასლის შენახვა XML-ად..."), + ("templates.saveAs", "Save as Template...", "შაბლონად შენახვა..."), + ("file.incrementAndSave", "Increment and Save", "ვერსიის გაზრდით შენახვა"), + ("file.revert", "Revert", "წინა ვერსიაზე დაბრუნება"), + ("", "Import", "იმპორტი"), + ("file.import", "File...", "ფაილი..."), + ("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."), + ("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.importSolid", "Solid...", "სოლიდი..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("", "Import Recent Footage", "ბოლო ვიდეომასალის იმპორტი"), + ("file.clearRecentFootage", "Clear Recent Footage", "ბოლო ვიდეომასალის სიის გასუფთავება"), + ("", "Export", "ექსპორტი"), + ("renderQueue.add", "Add to Render Queue", "რენდერის რიგში დამატება"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("app.find", "Find", "ძებნა"), + ("layer.addItem", "Add Footage to Comp", "ვიდეომასალის კომპოზიციაში დამატება"), + ("file.newCompFromSelection", "New Comp from Selection...", "ახალი კომპოზიცია მონიშნულიდან..."), + ("", "Dependencies", "დამოკიდებულებები"), + ("file.collectFiles", "Collect Files...", "ფაილების შეგროვება..."), + ("file.consolidateFootage", "Consolidate All Footage", "მთელი ვიდეომასალის კონსოლიდაცია"), + ("file.removeUnusedFootage", "Remove Unused Footage", "გამოუყენებელი ვიდეომასალის წაშლა"), + ("file.reduceProject", "Reduce Project", "პროექტის შემცირება"), + ("file.findMissing", "Find Missing Effects", "დაკარგული ეფექტების ძებნა"), + ("file.findMissing", "Find Missing Fonts", "დაკარგული შრიფტების ძებნა"), + ("file.findMissing", "Find Missing Footage", "დაკარგული ვიდეომასალის ძებნა"), + ("file.relinkFootage", "Relink Missing Footage...", "დაკარგული ვიდეომასალის ხელახლა დაკავშირება..."), + ("file.watchFolder", "Watch Folder...", "საქაღალდეზე თვალყურის დევნება..."), + ("", "Scripts", "სკრიპტები"), + ("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."), + ("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."), + ("", "Create Proxy", "პროქსის შექმნა"), + ("file.createProxy", "Still...", "სტატიკური კადრი..."), + ("file.createProxy", "Movie...", "ვიდეო..."), + ("", "Set Proxy", "პროქსის მინიჭება"), + ("file.setProxy", "File...", "ფაილი..."), + ("file.setProxyNone", "None", "არცერთი"), + ("", "Interpret Footage", "ვიდეომასალის ინტერპრეტაცია"), + ("file.interpretFootage", "Main...", "ძირითადი..."), + ("file.interpretProxy", "Proxy...", "პროქსი..."), + ("file.rememberInterpretation", "Remember Interpretation", "ინტერპრეტაციის დამახსოვრება"), + ("file.applyInterpretation", "Apply Interpretation", "ინტერპრეტაციის გამოყენება"), + ("", "Replace Footage", "ვიდეომასალის ჩანაცვლება"), + ("file.replaceFootage", "File...", "ფაილი..."), + ("file.replaceWithPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.replaceWithSolid", "Solid...", "სოლიდი..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "შრეებიანი კომპოზიციით"), + ("file.reloadFootage", "Reload Footage", "ვიდეომასალის ხელახლა ჩატვირთვა"), + ("file.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("file.projectSettings", "Project Settings...", "პროექტის პარამეტრები..."), + ("app.quit", "Exit", "გასვლა"), + ("", "Edit", "რედაქტირება"), + ("edit.undo", "Undo", "დაბრუნება"), + ("edit.redo", "Redo", "ხელახლა შესრულება"), + ("", "History", "ისტორია"), + ("app.commandPalette", "Quick Apply...", "სწრაფი გამოყენება..."), + ("edit.cut", "Cut", "ამოჭრა"), + ("edit.copy", "Copy", "კოპირება"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "კოპირება თვისებების ბმულებით"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "კოპირება თვისებების ფარდობითი ბმულებით"), + ("edit.copyExpressionOnly", "Copy Expression Only", "მხოლოდ ექსპრესიის კოპირება"), + ("edit.paste", "Paste", "ჩასმა"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "საკვანძო კადრების შებრუნებული ჩასმა"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "ტექსტის ჩასმა ფორმატის შეთანხმებით"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "მხოლოდ ტექსტის ფორმატის ჩასმა"), + ("edit.clear", "Clear", "გასუფთავება"), + ("edit.duplicate", "Duplicate", "დუბლირება"), + ("edit.splitLayer", "Split Layer", "შრის დაყოფა"), + ("edit.liftWorkArea", "Lift Work Area", "სამუშაო არის აწევა"), + ("edit.extractWorkArea", "Extract Work Area", "სამუშაო არის ამოღება"), + ("edit.selectAll", "Select All", "ყველაფრის მონიშვნა"), + ("edit.deselectAll", "Deselect All", "ყველა მონიშვნის მოხსნა"), + ("", "Label", "იარლიყი"), + ("edit.selectLabelGroup", "Select Label Group", "იარლიყის ჯგუფის მონიშვნა"), + ("edit.label", "None", "არცერთი"), + ("edit.label", "Red", "წითელი"), + ("edit.label", "Yellow", "ყვითელი"), + ("edit.label", "Aqua", "ზღვისფერი"), + ("edit.label", "Pink", "ვარდისფერი"), + ("edit.label", "Lavender", "ლავანდისფერი"), + ("edit.label", "Peach", "ატმისფერი"), + ("edit.label", "Sea Foam", "ზღვის ქაფი"), + ("edit.label", "Blue", "ლურჯი"), + ("edit.label", "Green", "მწვანე"), + ("edit.label", "Purple", "იისფერი"), + ("edit.label", "Orange", "ნარინჯისფერი"), + ("edit.label", "Brown", "ყავისფერი"), + ("edit.label", "Fuchsia", "ფუქსია"), + ("edit.label", "Cyan", "ცისფერი"), + ("edit.label", "Sandstone", "ქვიშაქვა"), + ("edit.label", "Dark Green", "მუქი მწვანე"), + ("app.settings", "Edit Label Colors...", "იარლიყის ფერების რედაქტირება..."), + ("", "Select Keyframe Label Group", "საკვანძო კადრების იარლიყის ჯგუფის მონიშვნა"), + ("keys.selectLabelGroup", "On Selected Layers", "მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "On All Layers", "ყველა შრეზე"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "ხილული საკვანძო კადრები მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "ხილული საკვანძო კადრები ყველა შრეზე"), + ("", "Purge", "გაწმენდა"), + ("edit.purge", "All Cache...", "მთელი ქეში..."), + ("edit.purge", "All Memory & Disk Cache...", "მთელი მეხსიერება და დისკის ქეში..."), + ("edit.purge", "All Memory", "მთელი მეხსიერება"), + ("edit.purge", "All Disk Cache...", "მთელი დისკის ქეში..."), + ("edit.purge", "All 3D Cache...", "მთელი 3D ქეში..."), + ("edit.purgeUndo", "Undo", "დაბრუნების ისტორია"), + ("edit.purge", "Image Cache Memory", "გამოსახულების ქეშის მეხსიერება"), + ("edit.purge", "Snapshot", "მომენტალური ასლი"), + ("edit.editOriginal", "Edit Original...", "ორიგინალის რედაქტირება..."), + ("", "Templates", "შაბლონები"), + ("app.templates", "Render Settings...", "რენდერის პარამეტრები..."), + ("app.templates", "Output Module...", "გამოტანის მოდული..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "კლავიატურის მალსახმობები"), + ("", "Preferences", "პარამეტრები"), + ("", "Composition", "კომპოზიცია"), + ("comp.new", "New Composition...", "ახალი კომპოზიცია..."), + ("comp.settings", "Composition Settings...", "კომპოზიციის პარამეტრები..."), + ("comp.setPosterTime", "Set Poster Time", "პოსტერის დროის დაყენება"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "კომპოზიციის მოკვეთა სამუშაო არემდე"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "კომპოზიციის ჩამოჭრა ინტერესის არემდე"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "კომპოზიციის ჩამოჭრა მონიშნული შრ(ეებ)ის საზღვრებამდე"), + ("render.addOutputModule", "Add Output Module", "გამოტანის მოდულის დამატება"), + ("", "Preview", "წინასწარი ნახვა"), + ("playback.toggle", "Play Current Preview", "მიმდინარე წინასწარი ნახვის დაკვრა"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("playback.audio", "Audio", "აუდიო"), + ("", "Save Frame As", "კადრის შენახვა როგორც"), + ("comp.saveFrameAs", "File...", "ფაილი..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-ის შრეები..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "კადრის კოპირება გაცვლის ბუფერში"), + ("render.preRender", "Pre-render...", "წინასწარი რენდერი..."), + ("render.saveCurrentPreview", "Save Current Preview...", "მიმდინარე წინასწარი ნახვის შენახვა..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("", "Responsive Design — Time", "რესპონსიული დიზაინი — დრო"), + ("comp.responsiveTime", "Create Intro", "შესავლის შექმნა"), + ("comp.responsiveTime", "Create Outro", "დასასრულის შექმნა"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "დაცული არის შექმნა სამუშაო არიდან"), + ("comp.flowchart", "Composition Flowchart", "კომპოზიციის ბლოკ-სქემა"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "VR გარემოს შექმნა..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "კუბური რუკის ამოღება..."), + ("", "Layer", "შრე"), + ("layer.newText", "Text", "ტექსტი"), + ("layer.newSolid", "Solid...", "სოლიდი..."), + ("layer.newLight", "Light...", "სინათლე..."), + ("layer.newCamera", "Camera...", "კამერა..."), + ("layer.newNull", "Null Object", "ნულოვანი ობიექტი"), + ("layer.newShape", "Shape Layer", "ფიგურის შრე"), + ("layer.newAdjustment", "Adjustment Layer", "კორექციის შრე"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "შიგთავსის გათვალისწინებით შევსების შრე..."), + ("layer.new3dPrimitive", "Cube", "კუბი"), + ("layer.new3dPrimitive", "Sphere", "სფერო"), + ("layer.new3dPrimitive", "Plane", "სიბრტყე"), + ("layer.new3dPrimitive", "Torus", "ტორი"), + ("layer.new3dPrimitive", "Cone", "კონუსი"), + ("layer.new3dPrimitive", "Cylinder", "ცილინდრი"), + ("layer.settings", "Layer Settings...", "შრის პარამეტრები..."), + ("layer.openLayer", "Open Layer", "შრის გახსნა"), + ("layer.openSource", "Open Layer Source", "შრის წყაროს გახსნა"), + ("layer.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("", "Mask", "ნიღაბი"), + ("layer.addMask", "New Mask", "ახალი ნიღაბი"), + ("layer.mask.shape", "Mask Shape...", "ნიღბის ფორმა..."), + ("layer.mask.set", "Mask Feather...", "ნიღბის კიდეების შერბილება..."), + ("layer.mask.set", "Mask Opacity...", "ნიღბის გაუმჭვირვალობა..."), + ("layer.mask.set", "Mask Expansion...", "ნიღბის გაფართოება..."), + ("layer.mask.reset", "Reset Mask", "ნიღბის ჩამოყრა"), + ("layer.mask.remove", "Remove Mask", "ნიღბის წაშლა"), + ("layer.mask.removeAll", "Remove All Masks", "ყველა ნიღბის წაშლა"), + ("track.mask", "Track Mask", "ნიღბის ტრეკინგი"), + ("", "Mode", "რეჟიმი"), + ("layer.mask.mode", "None", "არცერთი"), + ("layer.mask.mode", "Add", "დამატება"), + ("layer.mask.mode", "Subtract", "გამოკლება"), + ("layer.mask.mode", "Intersect", "გადაკვეთა"), + ("layer.mask.mode", "Lighten", "გაღიავება"), + ("layer.mask.mode", "Darken", "ჩამუქება"), + ("layer.mask.mode", "Difference", "სხვაობა"), + ("layer.mask.invert", "Inverted", "შებრუნებული"), + ("layer.mask.lock", "Locked", "ჩაკეტილი"), + ("", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.mask.motionBlur", "Same As Layer", "შრის მსგავსად"), + ("layer.mask.motionBlur", "On", "ჩართული"), + ("layer.mask.motionBlur", "Off", "გამორთული"), + ("", "Feather Falloff", "კიდეების შერბილების მილევა"), + ("layer.mask.featherFalloff", "Smooth", "გლუვი"), + ("layer.mask.featherFalloff", "Linear", "წრფივი"), + ("layer.mask.unlockAll", "Unlock All Masks", "ყველა ნიღბის განბლოკვა"), + ("layer.mask.lockOthers", "Lock Other Masks", "სხვა ნიღბების ჩაკეტვა"), + ("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"), + ("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "შეკრული"), + ("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"), + ("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"), + ("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("", "Quality", "ხარისხი"), + ("layer.quality", "Best", "საუკეთესო"), + ("layer.quality", "Draft", "მონახაზი"), + ("layer.quality", "Wireframe", "კარკასი"), + ("layer.sampling", "Bilinear", "ბიწრფივი"), + ("layer.sampling", "Bicubic", "ბიკუბური"), + ("", "Switches", "გადამრთველები"), + ("layer.hideOtherVideo", "Hide Other Video", "სხვა ვიდეოს დამალვა"), + ("layer.showAllVideo", "Show All Video", "მთელი ვიდეოს ჩვენება"), + ("layer.unlockAll", "Unlock All Layers", "ყველა შრის განბლოკვა"), + ("layer.expressions", "Enable Expressions", "ექსპრესიების ჩართვა"), + ("layer.expressions", "Disable Expressions", "ექსპრესიების გამორთვა"), + ("layer.setSwitch", "Shy", "მორცხვი"), + ("layer.setSwitch", "Lock", "ჩაკეტვა"), + ("layer.setSwitch", "Audio", "აუდიო"), + ("layer.setSwitch", "Video", "ვიდეო"), + ("layer.setSwitch", "Solo", "სოლო"), + ("layer.setSwitch", "Effect", "ეფექტი"), + ("layer.setSwitch", "Collapse", "ჩაკეცვა"), + ("layer.setSwitch", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.setSwitch", "Adjustment Layer", "კორექციის შრე"), + ("", "Transform", "გარდაქმნა"), + ("layer.transform", "Reset", "ჩამოყრა"), + ("layer.setTransform", "Anchor Point...", "საყრდენი წერტილი..."), + ("layer.setTransform", "Position...", "პოზიცია..."), + ("layer.setTransform", "Scale...", "მასშტაბი..."), + ("layer.setTransform", "Orientation...", "ორიენტაცია..."), + ("layer.setTransform", "Rotation...", "შემობრუნება..."), + ("layer.setTransform", "Opacity...", "გაუმჭვირვალობა..."), + ("layer.transform", "Flip Horizontal", "ჰორიზონტალურად არეკვლა"), + ("layer.transform", "Flip Vertical", "ვერტიკალურად არეკვლა"), + ("layer.transform", "Center In View", "ხედის ცენტრში მოთავსება"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "საყრდენი წერტილის შრის შიგთავსის ცენტრში მოთავსება"), + ("layer.transform", "Fit to Comp", "კომპოზიციაში ჩატევა"), + ("layer.transform", "Fit to Comp Width", "კომპოზიციის სიგანეში ჩატევა"), + ("layer.transform", "Fit to Comp Height", "კომპოზიციის სიმაღლეში ჩატევა"), + ("layer.autoOrient", "Auto-Orient...", "ავტოორიენტაცია..."), + ("", "Time", "დრო"), + ("layer.enableTimeRemap", "Enable Time Remapping", "დროის გადანაწილების ჩართვა"), + ("layer.timeReverse", "Time-Reverse Layer", "შრის დროში შებრუნება"), + ("layer.timeStretch", "Time Stretch...", "დროში გაწელვა..."), + ("layer.freezeFrame", "Freeze Frame", "კადრის გაყინვა"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "ბოლო კადრზე გაყინვა"), + ("layer.alignVideoToData", "Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("", "Frame Blending", "კადრების შერევა"), + ("layer.frameBlending", "Off", "გამორთული"), + ("layer.frameBlending", "Frame Mix", "კადრების ნაზავი"), + ("layer.frameBlending", "Pixel Motion", "პიქსელების მოძრაობა"), + ("layer.setSwitch", "3D Layer", "3D შრე"), + ("layer.setSwitch", "Guide Layer", "დამხმარე შრე"), + ("layer.environment", "Environment Layer", "გარემოს შრე"), + ("", "Markers", "მარკერები"), + ("layer.addMarker", "Add Marker", "მარკერის დამატება"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "მარკერების განახლება წყაროდან"), + ("layer.markersLock", "Lock Markers", "მარკერების ჩაკეტვა"), + ("layer.deleteAllMarkers", "Delete All Markers", "ყველა მარკერის წაშლა"), + ("layer.setSwitch", "Preserve Transparency", "გამჭვირვალობის შენარჩუნება"), + ("", "Blending Mode", "შერევის რეჟიმი"), + ("layer.setBlendMode", "Normal", "ჩვეულებრივი"), + ("layer.setBlendMode", "Dissolve", "გაფანტვა"), + ("layer.setBlendMode", "Dancing Dissolve", "მოცეკვავე გაფანტვა"), + ("layer.setBlendMode", "Darken", "ჩამუქება"), + ("layer.setBlendMode", "Multiply", "გამრავლება"), + ("layer.setBlendMode", "Color Burn", "ფერით ჩამუქება"), + ("layer.setBlendMode", "Classic Color Burn", "კლასიკური ფერით ჩამუქება"), + ("layer.setBlendMode", "Linear Burn", "წრფივი ჩამუქება"), + ("layer.setBlendMode", "Darker Color", "უფრო მუქი ფერი"), + ("layer.setBlendMode", "Add", "დამატება"), + ("layer.setBlendMode", "Lighten", "გაღიავება"), + ("layer.setBlendMode", "Screen", "ეკრანი"), + ("layer.setBlendMode", "Color Dodge", "ფერით გაღიავება"), + ("layer.setBlendMode", "Classic Color Dodge", "კლასიკური ფერით გაღიავება"), + ("layer.setBlendMode", "Linear Dodge", "წრფივი გაღიავება"), + ("layer.setBlendMode", "Lighter Color", "უფრო ღია ფერი"), + ("layer.setBlendMode", "Overlay", "გადაფარვა"), + ("layer.setBlendMode", "Soft Light", "რბილი სინათლე"), + ("layer.setBlendMode", "Hard Light", "მკვეთრი სინათლე"), + ("layer.setBlendMode", "Linear Light", "წრფივი სინათლე"), + ("layer.setBlendMode", "Vivid Light", "კაშკაშა სინათლე"), + ("layer.setBlendMode", "Pin Light", "წერტილოვანი სინათლე"), + ("layer.setBlendMode", "Hard Mix", "მკვეთრი შერევა"), + ("layer.setBlendMode", "Difference", "სხვაობა"), + ("layer.setBlendMode", "Classic Difference", "კლასიკური სხვაობა"), + ("layer.setBlendMode", "Exclusion", "გამორიცხვა"), + ("layer.setBlendMode", "Subtract", "გამოკლება"), + ("layer.setBlendMode", "Divide", "გაყოფა"), + ("layer.setBlendMode", "Hue", "ფერის ტონი"), + ("layer.setBlendMode", "Saturation", "გაჯერება"), + ("layer.setBlendMode", "Color", "ფერი"), + ("layer.setBlendMode", "Luminosity", "ნათება"), + ("layer.setBlendMode", "Stencil Alpha", "ალფა-ტრაფარეტი"), + ("layer.setBlendMode", "Stencil Luma", "ლუმა-ტრაფარეტი"), + ("layer.setBlendMode", "Silhouette Alpha", "ალფა-სილუეტი"), + ("layer.setBlendMode", "Silhouette Luma", "ლუმა-სილუეტი"), + ("layer.setBlendMode", "Alpha Add", "ალფას დამატება"), + ("layer.setBlendMode", "Luminescent Premul", "მანათობელი წინასწარ გამრავლებული"), + ("layer.setBlendMode", "Next Blending Mode", "შემდეგი შერევის რეჟიმი"), + ("layer.setBlendMode", "Previous Blending Mode", "წინა შერევის რეჟიმი"), + ("", "Track Matte", "ტრეკ-მატი"), + ("layer.trackMatte", "No Track Matte", "ტრეკ-მატის გარეშე"), + ("layer.trackMatte", "Alpha Matte", "ალფა-მატი"), + ("layer.trackMatte", "Alpha Inverted Matte", "შებრუნებული ალფა-მატი"), + ("layer.trackMatte", "Luma Matte", "ლუმა-მატი"), + ("layer.trackMatte", "Luma Inverted Matte", "შებრუნებული ლუმა-მატი"), + ("layer.trackMatte", "Matte with Layer Above", "მატი ზედა შრით"), + ("layer.trackMatte", "Matte with Layer Below", "მატი ქვედა შრით"), + ("", "Layer Styles", "შრის სტილები"), + ("layer.style.options", "Layer Style Options...", "შრის სტილის პარამეტრები..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "რედაქტირებად სტილებად გარდაქმნა"), + ("layer.style.showAll", "Show All", "ყველას ჩვენება"), + ("layer.style.removeAll", "Remove All", "ყველას წაშლა"), + ("layer.style.dropShadow", "Drop Shadow", "ჩრდილი"), + ("layer.style.innerShadow", "Inner Shadow", "შიდა ჩრდილი"), + ("layer.style.outerGlow", "Outer Glow", "გარე ნათება"), + ("layer.style.innerGlow", "Inner Glow", "შიდა ნათება"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "ფასკა და რელიეფი"), + ("layer.style.satin", "Satin", "ატლასი"), + ("layer.style.colorOverlay", "Color Overlay", "ფერის გადაფარვა"), + ("layer.style.gradientOverlay", "Gradient Overlay", "გრადიენტის გადაფარვა"), + ("layer.style.stroke", "Stroke", "კონტური"), + ("path.groupShapes", "Group Shapes", "ფიგურების დაჯგუფება"), + ("path.ungroupShapes", "Ungroup Shapes", "ფიგურების დაჯგუფების მოხსნა"), + ("", "Arrange", "დალაგება"), + ("layer.arrange", "Bring Layer to Front", "შრის წინა პლანზე გადმოტანა"), + ("layer.arrange", "Bring Layer Forward", "შრის წინ გადმოტანა"), + ("layer.arrange", "Send Layer Backward", "შრის უკან გადატანა"), + ("layer.arrange", "Send Layer to Back", "შრის უკანა პლანზე გადატანა"), + ("", "Reveal", "ჩვენება"), + ("layer.revealSource", "Reveal Layer Source in Project", "შრის წყაროს ჩვენება პროექტში"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "შრის ჩვენება პროექტის ბლოკ-სქემაში"), + ("comp.revealInProject", "Reveal Composition in Project", "კომპოზიციის ჩვენება პროექტში"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "ექსპრესიის შეცდომების ჩვენება"), + ("", "Create", "შექმნა"), + ("layer.create", "Convert to Editable Text", "რედაქტირებად ტექსტად გარდაქმნა"), + ("layer.create", "Create Shapes from Text", "ფიგურების შექმნა ტექსტიდან"), + ("layer.create", "Create Masks from Text", "ნიღბების შექმნა ტექსტიდან"), + ("layer.create", "Create Shapes from Vector Layer", "ფიგურების შექმნა ვექტორული შრიდან"), + ("layer.create", "Create Keyframes from Data", "საკვანძო კადრების შექმნა მონაცემებიდან"), + ("layer.create", "Null Controllers for Positional Points", "ნულოვანი მართვის ობიექტები პოზიციის წერტილებისთვის"), + ("layer.create", "Null Controllers for Path Points", "ნულოვანი მართვის ობიექტები ტრაექტორიის წერტილებისთვის"), + ("layer.create", "Nulls Following Path Points", "ტრაექტორიის წერტილებს მიმყოლი ნულოვანი ობიექტები"), + ("layer.create", "Null Tracing Along Path", "ტრაექტორიის გასწვრივ მოძრავი ნულოვანი ობიექტი"), + ("layer.create", "Create 3D Layer Instance", "3D შრის ეგზემპლარის შექმნა"), + ("", "Camera", "კამერა"), + ("camera.fromView", "Create Camera from 3D View", "კამერის შექმნა 3D ხედიდან"), + ("camera.stereoRig", "Create Stereo 3D Rig", "სტერეო 3D სისტემის შექმნა"), + ("camera.orbitNull", "Create Orbit Null", "ორბიტის ნულოვანი ობიექტის შექმნა"), + ("camera.fromModel", "Create Cameras from 3D Model", "კამერების შექმნა 3D მოდელიდან"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "ფოკუსური მანძილის მიბმა ინტერესის წერტილზე"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "ფოკუსური მანძილის მიბმა შრეზე"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "ფოკუსური მანძილის დაყენება შრეზე"), + ("layer.cameraSettings", "Camera Settings...", "კამერის პარამეტრები..."), + ("view.reset3DView", "Reset 3D View", "3D ხედის ჩამოყრა"), + ("", "Light", "სინათლე"), + ("light.fromModel", "Create Lights from 3D Model", "სინათლის წყაროების შექმნა 3D მოდელიდან"), + ("light.controlWithCamera", "Control Light with Camera", "სინათლის მართვა კამერით"), + ("light.environmentBackground", "Create Environment Light Background Layer", "გარემოს სინათლის ფონური შრის შექმნა"), + ("", "Material", "მასალა"), + ("material.revealSource", "Reveal Material Source in Project", "მასალის წყაროს ჩვენება პროექტში"), + ("material.reset", "Reset Material", "მასალის ჩამოყრა"), + ("material.duplicateAssign", "Duplicate and Assign Material", "მასალის დუბლირება და მინიჭება"), + ("layer.autoTrace", "Auto-trace...", "ავტომატური ვექტორიზაცია..."), + ("layer.precompose", "Pre-compose...", "კომპოზიციაში მოთავსება..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "სცენების ჭრილების ამოცნობა..."), + ("", "Effect", "ეფექტი"), + ("window.panel", "Effect Controls", "ეფექტის მართვა"), + ("effect.applyLast", "Last Effect", "ბოლო ეფექტი"), + ("effect.removeAll", "Remove All", "ყველას წაშლა"), + ("effect.manage", "Manage Effects...", "ეფექტების მართვა..."), + ("effect.plugins.load", "Load Effect Plug-in...", "ეფექტის პლაგინის ჩატვირთვა..."), + ("", "Animation", "ანიმაცია"), + ("anim.savePreset", "Save Animation Preset...", "ანიმაციის პრესეტის შენახვა..."), + ("anim.applyPreset", "Apply Animation Preset...", "ანიმაციის პრესეტის გამოყენება..."), + ("", "Recent Animation Presets", "ბოლო ანიმაციის პრესეტები"), + ("anim.clearRecentPresets", "Clear Recent Presets", "ბოლო პრესეტების სიის გასუფთავება"), + ("anim.browsePresets", "Browse Presets...", "პრესეტების დათვალიერება..."), + ("", "Text Animation Presets", "ტექსტის ანიმაციის პრესეტები"), + ("layer.applyTextPreset", "Typewriter", "საბეჭდი მანქანა"), + ("layer.applyTextPreset", "Fade Up Characters", "ასოების ამოსვლა გამოჩენით"), + ("layer.applyTextPreset", "Bounce In Words", "სიტყვების ხტუნვით შემოსვლა"), + ("layer.applyTextPreset", "Tracking In", "ასოების შეკუმშვით შემოსვლა"), + ("layer.applyTextPreset", "Scramble", "არევა"), + ("layer.applyTextPreset", "Blur In", "გამოჩენა გაბუნდოვნებიდან"), + ("layer.applyTextPreset", "Jitter", "კანკალი"), + ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), + ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), + ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), + ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), + ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "აუდიოს საკვანძო კადრებად გარდაქმნა"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "ექსპრესიის საკვანძო კადრებად გარდაქმნა"), + ("keys.easyEase", "Easy Ease", "რბილი შენელება"), + ("keys.easyEaseIn", "Easy Ease In", "რბილი შენელება შესვლისას"), + ("keys.easyEaseOut", "Easy Ease Out", "რბილი შენელება გამოსვლისას"), + ("keys.exponentialScale", "Exponential Scale", "ექსპონენციალური მასშტაბი"), + ("keys.rpfCameraImport", "RPF Camera Import", "RPF კამერის იმპორტი"), + ("layer.sequence", "Sequence Layers...", "შრეების თანმიმდევრობით განლაგება..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "საკვანძო კადრების დროში შებრუნება"), + ("", "Animate Text", "ტექსტის ანიმირება"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("layer.addTextAnimator", "Anchor Point", "საყრდენი წერტილი"), + ("layer.addTextAnimator", "Position", "პოზიცია"), + ("layer.addTextAnimator", "Scale", "მასშტაბი"), + ("layer.addTextAnimator", "Skew", "დახრა"), + ("layer.addTextAnimator", "Rotation", "შემობრუნება"), + ("layer.addTextAnimator", "Opacity", "გაუმჭვირვალობა"), + ("layer.addTextAnimator", "All Transform Properties", "გარდაქმნის ყველა თვისება"), + ("", "Fill Color", "შევსების ფერი"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "ფერის ტონი"), + ("layer.addTextAnimator", "Saturation", "გაჯერება"), + ("layer.addTextAnimator", "Brightness", "სიკაშკაშე"), + ("", "Stroke Color", "კონტურის ფერი"), + ("layer.addTextAnimator", "Stroke Width", "კონტურის სისქე"), + ("", "Front", "წინა მხარე"), + ("", "Bevel", "წაკვეთა"), + ("", "Side", "გვერდითი მხარე"), + ("", "Back", "უკანა მხარე"), + ("layer.addTextAnimator", "Color", "ფერი"), + ("layer.addTextAnimator", "Tracking", "ასოთაშორისი ინტერვალი"), + ("layer.addTextAnimator", "Line Anchor", "სტრიქონის საყრდენი"), + ("layer.addTextAnimator", "Line Spacing", "სტრიქონთა დაშორება"), + ("layer.addTextAnimator", "Character Offset", "ასოს წანაცვლება"), + ("layer.addTextAnimator", "Character Value", "ასოს მნიშვნელობა"), + ("layer.addTextAnimator", "Blur", "გაბუნდოვნება"), + ("text.animatorFontAxes", "Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("", "Add Text Selector", "ტექსტის სელექტორის დამატება"), + ("text.addSelector", "Range", "დიაპაზონი"), + ("text.addSelector", "Wiggly", "რხევითი"), + ("text.addSelector", "Expression", "ექსპრესია"), + ("text.removeAllAnimators", "Remove All Text Animators", "ტექსტის ყველა ანიმატორის წაშლა"), + ("prop.setExpression", "Add Expression", "ექსპრესიის დამატება"), + ("essential.addProperty", "Add Property to Essential Graphics", "თვისების დამატება ძირითად გრაფიკაში"), + ("prop.separateDimensions", "Separate Dimensions", "განზომილებების გაყოფა"), + ("track.camera", "Track Camera", "კამერის ტრეკინგი"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "მოძრაობის ტრეკინგი"), + ("track.stabilize", "Stabilize Motion", "მოძრაობის სტაბილიზაცია"), + ("track.property", "Track this Property", "ამ თვისების ტრეკინგი"), + ("anim.reveal", "Reveal Properties with Keyframes", "საკვანძო კადრების მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal Properties with Animation", "ანიმაციის მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal All Modified Properties", "ყველა შეცვლილი თვისების ჩვენება"), + ("", "View", "ხედი"), + ("view.newViewer", "New Viewer", "ახალი დამთვალიერებელი"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "დაყოფა ახალი ჩაკეტილი დამთვალიერებელით"), + ("view.zoomIn", "Zoom In", "გადიდება"), + ("view.zoomOut", "Zoom Out", "დაპატარავება"), + ("", "Resolution", "გარჩევადობა"), + ("view.res.full", "Full", "სრული"), + ("view.res.half", "Half", "ნახევარი"), + ("view.res.third", "Third", "მესამედი"), + ("view.res.quarter", "Quarter", "მეოთხედი"), + ("view.res.custom", "Custom...", "მორგებული..."), + ("view.displayColorManagement", "Use Display Color Management", "ეკრანის ფერთა მართვის გამოყენება"), + ("", "Simulate Output", "გამოტანის იმიტაცია"), + ("view.simulateOutput", "No Output Simulation", "გამოტანის იმიტაციის გარეშე"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "ძველი Macintosh RGB (გამა 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "წრფივი (გამა 1.0)"), + ("view.customRgb", "My Custom RGB...", "ჩემი მორგებული RGB..."), + ("view.simulateOutput", "Custom...", "მორგებული..."), + ("view.rulers", "Show Rulers", "სახაზავების ჩვენება"), + ("", "Panel Background Color", "პანელის ფონის ფერი"), + ("view.panelBackground", "Black", "შავი"), + ("view.panelBackground", "Dark Gray", "მუქი ნაცრისფერი"), + ("view.panelBackground", "Medium Gray (Default)", "საშუალო ნაცრისფერი (ნაგულისხმევი)"), + ("view.panelBackground", "Light Gray", "ღია ნაცრისფერი"), + ("view.panelBackground", "White", "თეთრი"), + ("view.panelBackground", "Custom", "მორგებული"), + ("view.panelBackground", "Select Custom Background Color...", "მორგებული ფონის ფერის არჩევა..."), + ("view.guides", "Show Guides", "დამხმარე ხაზების ჩვენება"), + ("view.snapToGuides", "Snap to Guides", "დამხმარე ხაზებზე მიბმა"), + ("view.lockGuides", "Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("view.addGuide", "Add Guide...", "დამხმარე ხაზის დამატება..."), + ("view.addPercentGuide", "Add Percentage Guide...", "პროცენტული დამხმარე ხაზის დამატება..."), + ("view.convertGuides", "Convert Guides to Percentage", "დამხმარე ხაზების პროცენტულად გარდაქმნა"), + ("view.convertGuides", "Convert Guides to Pixels", "დამხმარე ხაზების პიქსელებად გარდაქმნა"), + ("view.clearGuides", "Clear Guides", "დამხმარე ხაზების გასუფთავება"), + ("view.importGuides", "Import Guides...", "დამხმარე ხაზების იმპორტი..."), + ("view.exportGuides", "Export Guides...", "დამხმარე ხაზების ექსპორტი..."), + ("view.grid", "Show Grid", "ბადის ჩვენება"), + ("view.snapToGrid", "Snap to Grid", "ბადეზე მიბმა"), + ("view.snapping", "Snapping", "მიბმა"), + ("view.options", "View Options...", "ხედის პარამეტრები..."), + ("view.layerControls", "Show Layer Controls", "შრის მართვის ელემენტების ჩვენება"), + ("", "Switch View Layout", "ხედების განლაგების გადართვა"), + ("view.layout", "1 View", "1 ხედი"), + ("view.layout", "2 Views", "2 ხედი"), + ("view.layout", "4 Views", "4 ხედი"), + ("view.shareViewOptions", "Share View Options", "ხედის პარამეტრების გაზიარება"), + ("", "Switch 3D View", "3D ხედის გადართვა"), + ("view.3d.activeCamera", "Active Camera", "აქტიური კამერა"), + ("view.3d.default", "Default", "ნაგულისხმევი"), + ("view.3d.front", "Front", "წინიდან"), + ("view.3d.left", "Left", "მარცხნიდან"), + ("view.3d.top", "Top", "ზემოდან"), + ("view.3d.back", "Back", "უკნიდან"), + ("view.3d.right", "Right", "მარჯვნიდან"), + ("view.3d.bottom", "Bottom", "ქვემოდან"), + ("view.3d.custom1", "Custom View 1", "მორგებული ხედი 1"), + ("view.3d.custom2", "Custom View 2", "მორგებული ხედი 2"), + ("view.3d.custom3", "Custom View 3", "მორგებული ხედი 3"), + ("", "Assign Shortcut to 3D View", "მალსახმობის მინიჭება 3D ხედისთვის"), + ("view.3d.last", "Switch to Last 3D View", "ბოლო 3D ხედზე გადართვა"), + ("view.lookAtSelected", "Look at Selected Layers", "მონიშნული შრეების დათვალიერება"), + ("view.lookAtAll", "Look at All Layers", "ყველა შრის დათვალიერება"), + ("time.set", "Go to Time...", "დროზე გადასვლა..."), + ("view.fullScreen", "Enter Full Screen", "სრულეკრანიან რეჟიმში გადასვლა"), + ("", "Window", "ფანჯარა"), + ("", "Workspace", "სამუშაო სივრცე"), + ("window.workspace", "Default", "ნაგულისხმევი"), + ("window.workspace", "Review", "განხილვა"), + ("window.workspace", "Learn", "სწავლა"), + ("window.workspace", "Small Screen", "პატარა ეკრანი"), + ("window.workspace", "Standard", "სტანდარტული"), + ("window.workspace", "All Panels", "ყველა პანელი"), + ("window.workspace", "Animation", "ანიმაცია"), + ("window.workspace", "Color", "ფერი"), + ("window.workspace", "Effects", "ეფექტები"), + ("window.workspace", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.workspace", "Minimal", "მინიმალური"), + ("window.workspace", "Motion Tracking", "მოძრაობის ტრეკინგი"), + ("window.workspace", "Paint", "ხატვა"), + ("window.workspace", "Text", "ტექსტი"), + ("window.workspace", "Undocked Panels", "მოხსნილი პანელები"), + ("window.resetWorkspace", "Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("window.saveWorkspace", "Save Changes to this Workspace", "ამ სამუშაო სივრცეში ცვლილებების შენახვა"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "ახალ სამუშაო სივრცედ შენახვა..."), + ("window.editWorkspaces", "Edit Workspaces...", "სამუშაო სივრცეების რედაქტირება..."), + ("", "Assign Shortcut to Workspace", "მალსახმობის მინიჭება სამუშაო სივრცისთვის"), + ("window.panel", "Align", "სწორება"), + ("window.panel", "Audio", "აუდიო"), + ("window.panel", "Brushes", "ფუნჯები"), + ("window.panel", "Character", "სიმბოლო"), + ("window.panel", "Content-Aware Fill", "შიგთავსის გათვალისწინებით შევსება"), + ("window.panel", "Effects & Presets", "ეფექტები და პრესეტები"), + ("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.panel", "Info", "ინფორმაცია"), + ("help.inAppTutorials", "Learn", "სწავლა"), + ("window.panel", "Lumetri Scopes", "Lumetri Scopes"), + ("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"), + ("window.panel", "Media Browser", "მედიის ბრაუზერი"), + ("window.panel", "Metadata", "მეტამონაცემები"), + ("window.panel", "Motion Sketch", "მოძრაობის ესკიზი"), + ("window.panel", "Paint", "ხატვა"), + ("window.panel", "Paragraph", "აბზაცი"), + ("window.panel", "Preview", "წინასწარი ნახვა"), + ("window.panel", "Progress", "პროგრესი"), + ("window.panel", "Properties", "თვისებები"), + ("window.panel", "Script Console", "სკრიპტის კონსოლი"), + ("window.panel", "Smoother", "დამგლუვებელი"), + ("window.panel", "Tools", "ხელსაწყოები"), + ("window.panel", "Tracker", "ტრეკერი"), + ("window.panel", "Wiggler", "რხევის გენერატორი"), + ("window.panel", "Composition", "კომპოზიცია"), + ("window.panel", "Flowchart", "ბლოკ-სქემა"), + ("window.panel", "Footage", "ვიდეომასალა"), + ("window.panel", "Layer", "შრე"), + ("window.panel", "Project", "პროექტი"), + ("window.panel", "Render Queue", "რენდერის რიგი"), + ("window.panel", "Timeline", "დროის ხაზი"), + ("window.panel", "Create Nulls From Paths", "ნულოვანი ობიექტების შექმნა ტრაექტორიებიდან"), + ("window.panel", "VR Comp Editor", "VR კომპოზიციის რედაქტორი"), + ("", "Help", "დახმარება"), + ("help.docs", "EffectCraft Help...", "EffectCraft-ის დახმარება..."), + ("help.docs", "Scripting Help...", "სკრიპტების დახმარება..."), + ("help.docs", "Expression Reference...", "ექსპრესიების ცნობარი..."), + ("help.docs", "Effect Reference...", "ეფექტების ცნობარი..."), + ("anim.browsePresets", "Animation Presets...", "ანიმაციის პრესეტები..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "კლავიატურის მალსახმობები..."), + ("help.inAppTutorials", "In-App Tutorials...", "აპის შიგნით გაკვეთილები..."), + ("help.onlineTutorials", "Online Tutorials...", "ონლაინ გაკვეთილები..."), + ("help.systemReport", "System Compatibility Report...", "სისტემის თავსებადობის ანგარიში..."), + ("help.enableLogging", "Enable Logging", "ჟურნალის ჩართვა"), + ("help.revealLogFile", "Reveal Logging File", "ჟურნალის ფაილის ჩვენება"), + ("help.discord", "Join the ArtCraft Discord...", "შემოუერთდით ArtCraft-ის Discord-ს..."), + ("help.reportIssue", "Provide Feedback...", "გამოხმაურების გაგზავნა..."), + ("help.website", "ArtCraft Website", "ArtCraft-ის ვებსაიტი"), + ("help.appPage", "EffectCraft Home Page", "EffectCraft-ის საწყისი გვერდი"), + ("help.github", "EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("file.openDemoProject", "Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), +]; + #[cfg(test)] mod tests { use super::*; @@ -6753,6 +7416,22 @@ mod tests { } } + #[test] + fn georgian_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Finder-ში ჩვენება"), + ("Reveal in Explorer", "Explorer-ში ჩვენება"), + ("Reveal in File Manager", "ფაილების მენეჯერში ჩვენება"), + ] { + assert_eq!(label(&app, command, source), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + } + } + fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -6784,6 +7463,7 @@ mod tests { let mut french_keys = BTreeSet::new(); let mut german_keys = BTreeSet::new(); let mut czech_keys = BTreeSet::new(); + let mut georgian_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), @@ -6795,6 +7475,7 @@ mod tests { (FRENCH, &mut french_keys), (GERMAN, &mut german_keys), (CZECH, &mut czech_keys), + (GEORGIAN, &mut georgian_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -6825,6 +7506,7 @@ mod tests { assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); + assert_eq!(japanese_keys, georgian_keys, "the languages must translate the same entries"); } /// Exercise each engine spelling on every host: a macOS-only run must still catch a @@ -6911,6 +7593,9 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("ka"), "ka"), + (Some("ka-GE"), "ka"), + (Some("ka_GE.UTF-8"), "ka"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -6944,9 +7629,15 @@ mod tests { #[test] fn every_translated_entry_keeps_the_english_shape() { - for (code, catalog) in - [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)] - { + for (code, catalog) in [ + ("zh-hans", SIMPLIFIED_CHINESE), + ("zh-hant", TRADITIONAL_CHINESE), + ("uk", UKRAINIAN), + ("es", SPANISH), + ("pt-br", PORTUGUESE), + ("cs", CZECH), + ("ka", GEORGIAN), + ] { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); @@ -7190,6 +7881,44 @@ mod tests { assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Atribuir atalho à visualização 3D “Top”"); } + #[test] + fn ka_translates_menus_without_changing_bindings_or_user_names() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); + let en = crate::native_menu::build(&app); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ka"})).unwrap(); + assert_eq!(language(&app), "ka"); + assert!(georgian(&app)); + assert!(!japanese(&app)); + let ka = crate::native_menu::build(&app); + let bindings = |menu: &crate::native_menu::NativeMenu| { + menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() + }; + assert_eq!(bindings(&en), bindings(&ka)); + assert_eq!(label(&app, "", "Composition"), "კომპოზიცია"); + assert_eq!(label(&app, "", "Layer"), "შრე"); + assert_eq!(label(&app, "comp.new", "New Composition..."), "ახალი კომპოზიცია..."); + assert_eq!(label(&app, "anim.addKeyframe", "Add Keyframe"), "საკვანძო კადრის დამატება"); + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + assert!(ka.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "რბილი შენელება")); + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "დაბრუნება შეუძლებელია"); + app.session.history.undo.push(("Comp 1".into(), app.session.project.clone())); + assert_eq!(entry(&app, &undo, "Undo Comp 1".into()), "დაბრუნება: Comp 1"); + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &panel, "Layer: File".into()), "შრე: File"); + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), + "მალსახმობის მინიჭება სამუშაო სივრცისთვის „Default“" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "მალსახმობის მინიჭება 3D ხედისთვის „Top“"); + // A dynamic filename is never translated. + app.session.prefs.push_recent("/tmp/File.ecproj"); + assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + assert!(effectcraft_engine::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3ab115582..f670e63a8 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -70,12 +70,14 @@ fn catalog(language: &str) -> Option<&'static Catalog> { static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() }; static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() }; static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() }; + static GEORGIAN_CATALOG: Catalog = Catalog { rows: GEORGIAN, categories: GEORGIAN_CATEGORIES, index: OnceLock::new() }; match language { "zh-hans" => Some(&SIMPLIFIED), "zh-hant" => Some(&TRADITIONAL), // Japanese: the slot the Japanese data point on #821 asked for, so both languages share // this one catalog instead of inventing a second mechanism. "ja" => Some(&JAPANESE), + "ka" => Some(&GEORGIAN_CATALOG), _ => None, } } @@ -447,6 +449,346 @@ const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; /// Japanese (`ja`) category labels, keyed by the registry's English category. const JAPANESE_CATEGORIES: &[(&str, &str)] = &[]; +/// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning +/// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian +/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`, +/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as +/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix. +const GEORGIAN: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "სიღრმის მატი"), + ("ec.3d.depthoffield", "Depth of Field", "ველის სიღრმე"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.audio.backwards", "Backwards", "უკუღმა"), + ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), + ("ec.audio.compressor", "Compressor", "კომპრესორი"), + ("ec.audio.delay", "Delay", "დაყოვნება"), + ("ec.audio.distortion", "Distortion", "დამახინჯება"), + ("ec.audio.flangechorus", "Flange & Chorus", "ფლენჯერი და ქორუსი"), + ("ec.audio.gate", "Gate", "გეიტი"), + ("ec.audio.highlowpass", "High-Low Pass", "მაღალი/დაბალი სიხშირეების ფილტრი"), + ("ec.audio.modulator", "Modulator", "მოდულატორი"), + ("ec.audio.parametriceq", "Parametric EQ", "პარამეტრული ეკვალაიზერი"), + ("ec.audio.reverb", "Reverb", "რევერბი"), + ("ec.audio.stereomixer", "Stereo Mixer", "სტერეომიქშერი"), + ("ec.audio.tone", "Tone", "ტონი"), + ("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"), + ("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"), + ("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"), + ("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"), + ("ec.blur.fastbox", "Fast Box Blur", "სწრაფი ბლოკური გაბუნდოვნება"), + ("ec.blur.gaussian", "Gaussian Blur", "გაუსის გაბუნდოვნება"), + ("ec.blur.radial", "Radial Blur", "რადიალური გაბუნდოვნება"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "ნახევარკადრული ციმციმის შემცირება"), + ("ec.blur.sharpen", "Sharpen", "სიმკვეთრე"), + ("ec.blur.smart", "Smart Blur", "ჭკვიანი გაბუნდოვნება"), + ("ec.blur.unsharp", "Unsharp Mask", "არამკვეთრი ნიღაბი"), + ("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"), + ("ec.channel.blend", "Blend", "შერევა"), + ("ec.channel.calculations", "Calculations", "გამოთვლები"), + ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"), + ("ec.channel.invert", "Invert", "ინვერსია"), + ("ec.channel.minimax", "Minimax", "მინიმაქსი"), + ("ec.channel.removecolormatting", "Remove Color Matting", "ფერადი მატინგის მოცილება"), + ("ec.channel.setchannels", "Set Channels", "არხების დაყენება"), + ("ec.channel.setmatte", "Set Matte", "მატის დაყენება"), + ("ec.channel.shiftchannels", "Shift Channels", "არხების გადანაცვლება"), + ("ec.channel.solidcomposite", "Solid Composite", "სოლიდთან კომპოზიტი"), + ("ec.color.autocolor", "Auto Color", "ავტოფერი"), + ("ec.color.autocontrast", "Auto Contrast", "ავტოკონტრასტი"), + ("ec.color.autolevels", "Auto Levels", "ავტოდონეები"), + ("ec.color.blackwhite", "Black & White", "შავ-თეთრი"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"), + ("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "ფერის შეცვლა"), + ("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"), + ("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"), + ("ec.color.colorama", "Colorama", "კოლორამა"), + ("ec.color.colorbalance", "Color Balance", "ფერთა ბალანსი"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "ფერთა ბალანსი (HLS)"), + ("ec.color.colorlink", "Color Link", "ფერის ბმა"), + ("ec.color.colorstabilizer", "Color Stabilizer", "ფერის სტაბილიზატორი"), + ("ec.color.curves", "Curves", "მრუდები"), + ("ec.color.equalize", "Equalize", "გათანაბრება"), + ("ec.color.exposure", "Exposure", "ექსპოზიცია"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "გამა/საფეხური/გაძლიერება"), + ("ec.color.huesaturation", "Hue/Saturation", "ფერის ტონი/გაჯერება"), + ("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"), + ("ec.color.levels", "Levels", "დონეები"), + ("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"), + ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO ფაილის გარდაქმნა"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO ვიზუალური სტილის გარდაქმნა"), + ("ec.color.photofilter", "Photo Filter", "ფოტოფილტრი"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS ნებისმიერი რუკა"), + ("ec.color.selectivecolor", "Selective Color", "შერჩევითი ფერი"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "ჩრდილები/ღია ტონები"), + ("ec.color.tint", "Tint", "ტონირება"), + ("ec.color.tritone", "Tritone", "სამტონიანი"), + ("ec.color.vibrance", "Vibrance", "სიხალასე"), + ("ec.color.videolimiter", "Video Limiter", "ვიდეოლიმიტერი"), + ("ec.control.angle", "Angle Control", "კუთხის მართვა"), + ("ec.control.checkbox", "Checkbox Control", "მოსანიშნი ველის მართვა"), + ("ec.control.color", "Color Control", "ფერის მართვა"), + ("ec.control.dropdown", "Dropdown Menu Control", "ჩამოსაშლელი მენიუს მართვა"), + ("ec.control.layer", "Layer Control", "შრის მართვა"), + ("ec.control.point", "Point Control", "წერტილის მართვა"), + ("ec.control.point3d", "3D Point Control", "3D წერტილის მართვა"), + ("ec.control.slider", "Slider Control", "სლაიდერის მართვა"), + ("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"), + ("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"), + ("ec.distort.bulge", "Bulge", "გამობერილობა"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"), + ("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"), + ("ec.distort.liquify", "Liquify", "გათხევადება"), + ("ec.distort.magnify", "Magnify", "გადიდება"), + ("ec.distort.meshwarp", "Mesh Warp", "ბადისებრი დეფორმაცია"), + ("ec.distort.mirror", "Mirror", "სარკე"), + ("ec.distort.offset", "Offset", "წანაცვლება"), + ("ec.distort.opticscompensation", "Optics Compensation", "ოპტიკის კომპენსაცია"), + ("ec.distort.polar", "Polar Coordinates", "პოლარული კოორდინატები"), + ("ec.distort.puppet", "Puppet", "მარიონეტი"), + ("ec.distort.reshape", "Reshape", "ფორმის შეცვლა"), + ("ec.distort.ripple", "Ripple", "ტალღა"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "მცოცავი ჩამკეტის გასწორება"), + ("ec.distort.smear", "Smear", "გადღაბნა"), + ("ec.distort.spherize", "Spherize", "სფერიზება"), + ("ec.distort.transform", "Transform", "გარდაქმნა"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "ტურბულენტური წანაცვლება"), + ("ec.distort.twirl", "Twirl", "გრიგალი"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "გრიგალი (მოძველებული)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "გადიდება დეტალების შენარჩუნებით"), + ("ec.distort.warp", "Warp", "დეფორმაცია"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "დეფორმაციის სტაბილიზატორი"), + ("ec.distort.wavewarp", "Wave Warp", "ტალღური დეფორმაცია"), + ("ec.generate.advancedlightning", "Advanced Lightning", "გაფართოებული ელვა"), + ("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"), + ("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"), + ("ec.generate.beam", "Beam", "სხივი"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"), + ("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"), + ("ec.generate.circle", "Circle", "წრე"), + ("ec.generate.ellipse", "Ellipse", "ელიფსი"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "პიპეტით შევსება"), + ("ec.generate.fill", "Fill", "შევსება"), + ("ec.generate.fourcolor", "4-Color Gradient", "4-ფერიანი გრადიენტი"), + ("ec.generate.fractal", "Fractal", "ფრაქტალი"), + ("ec.generate.gradientramp", "Gradient Ramp", "გრადიენტული გადასვლა"), + ("ec.generate.grid", "Grid", "ბადე"), + ("ec.generate.lensflare", "Lens Flare", "ობიექტივის ანარეკლი"), + ("ec.generate.paintbucket", "Paint Bucket", "საღებავის ვედრო"), + ("ec.generate.radiowaves", "Radio Waves", "რადიოტალღები"), + ("ec.generate.scribble", "Scribble", "ჯღაბნა"), + ("ec.generate.stroke", "Stroke", "კონტური"), + ("ec.generate.vegas", "Vegas", "ვეგასი"), + ("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"), + ("ec.key.colorkey", "Color Key", "ფერის გასაღები"), + ("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"), + ("ec.key.differencematte", "Difference Matte", "სხვაობის მატი"), + ("ec.key.extract", "Extract", "ამოღება"), + ("ec.key.innerouter", "Inner/Outer Key", "შიდა/გარე გასაღები"), + ("ec.key.keycleaner", "Key Cleaner", "გასაღების გამწმენდი"), + ("ec.key.linearcolor", "Linear Color Key", "წრფივი ფერის გასაღები"), + ("ec.key.luma", "Luma Key", "სიკაშკაშის გასაღები"), + ("ec.key.screen", "Screen Key", "ეკრანის გასაღები"), + ("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"), + ("ec.key.unmult", "Unmult", "Unmult"), + ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"), + ("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"), + ("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"), + ("ec.matte.refinesoft", "Refine Soft Matte", "რბილი მატის დახვეწა"), + ("ec.matte.rotobrush", "Roto Brush & Refine Edge", "როტო-ფუნჯი და კიდის დახვეწა"), + ("ec.matte.simplechoker", "Simple Choker", "მარტივი შემკუმშველი"), + ("ec.noise.addgrain", "Add Grain", "მარცვლოვნების დამატება"), + ("ec.noise.curlnoise", "Curl Noise", "გრიგალური ხმაური"), + ("ec.noise.dustscratches", "Dust & Scratches", "მტვერი და ნაკაწრები"), + ("ec.noise.fractal", "Fractal Noise", "ფრაქტალური ხმაური"), + ("ec.noise.matchgrain", "Match Grain", "მარცვლოვნების მისადაგება"), + ("ec.noise.median", "Median", "მედიანა"), + ("ec.noise.medianlegacy", "Median (Legacy)", "მედიანა (მოძველებული)"), + ("ec.noise.noise", "Noise", "ხმაური"), + ("ec.noise.noisealpha", "Noise Alpha", "ალფა-ხმაური"), + ("ec.noise.noisehls", "Noise HLS", "HLS ხმაური"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "HLS ხმაური (ავტომატური)"), + ("ec.noise.removegrain", "Remove Grain", "მარცვლოვნების მოცილება"), + ("ec.noise.turbulent", "Turbulent Noise", "ტურბულენტური ხმაური"), + ("ec.obsolete.basic3d", "Basic 3D", "საბაზო 3D"), + ("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"), + ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"), + ("ec.obsolete.lightning", "Lightning", "ელვა"), + ("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"), + ("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"), + ("ec.paint.paint", "Paint", "ხატვა"), + ("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"), + ("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), + ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), + ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"), + ("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"), + ("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"), + ("ec.sim.caustics", "Caustics", "კაუსტიკა"), + ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), + ("ec.sim.cchair", "CC Hair", "CC Hair"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.foam", "Foam", "ქაფი"), + ("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"), + ("ec.sim.shatter", "Shatter", "დამსხვრევა"), + ("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"), + ("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"), + ("ec.stylize.emboss", "Emboss", "რელიეფი"), + ("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"), + ("ec.stylize.glow", "Glow", "ნათება"), + ("ec.stylize.mosaic", "Mosaic", "მოზაიკა"), + ("ec.stylize.motiontile", "Motion Tile", "მოძრავი ფილები"), + ("ec.stylize.posterize", "Posterize", "პოსტერიზაცია"), + ("ec.stylize.roughenedges", "Roughen Edges", "კიდეების დაუხეშება"), + ("ec.stylize.scatter", "Scatter", "გაბნევა"), + ("ec.stylize.strobe", "Strobe Light", "სტრობოსკოპი"), + ("ec.stylize.texturize", "Texturize", "ტექსტურირება"), + ("ec.stylize.threshold", "Threshold", "ზღვარი"), + ("ec.text.numbers", "Numbers", "რიცხვები"), + ("ec.text.timecode", "Timecode", "თაიმკოდი"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), + ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.echo", "Echo", "ექო"), + ("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"), + ("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"), + ("ec.time.timedifference", "Time Difference", "დროითი სხვაობა"), + ("ec.time.timedisplacement", "Time Displacement", "დროითი წანაცვლება"), + ("ec.time.timewarp", "Timewarp", "Timewarp"), + ("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"), + ("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), + ("ec.transition.cctwister", "CC Twister", "CC Twister"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"), + ("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"), + ("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"), + ("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"), + ("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"), + ("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"), + ("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"), + ("ec.utility.facetrackpoints", "Face Track Points", "სახის ტრეკინგის წერტილები"), + ("ec.utility.growbounds", "Grow Bounds", "საზღვრების გაზრდა"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR კომპანდერი"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ღია ტონების შეკუმშვა"), + ("ec.vr.blur", "VR Blur", "VR გაბუნდოვნება"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR ქრომატული აბერაციები"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR ფერადი გრადიენტები"), + ("ec.vr.converter", "VR Converter", "VR გარდამქმნელი"), + ("ec.vr.denoise", "VR De-Noise", "VR ხმაურის მოცილება"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR ციფრული ხარვეზი"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR ფრაქტალური ხმაური"), + ("ec.vr.glow", "VR Glow", "VR ნათება"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR სიბრტყიდან სფეროზე"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR სფეროს შემობრუნება"), + ("ec.vr.sharpen", "VR Sharpen", "VR სიმკვეთრე"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR სფეროდან სიბრტყეზე"), +]; + +/// Georgian (`ka`) category labels, keyed by the registry's English category. +const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[ + ("3D Channel", "3D არხი"), + ("Audio", "აუდიო"), + ("Blur & Sharpen", "გაბუნდოვნება და სიმკვეთრე"), + ("Channel", "არხი"), + ("Color Correction", "ფერის კორექცია"), + ("Distort", "დამახინჯება"), + ("Expression Controls", "ექსპრესიის მართვა"), + ("Generate", "გენერაცია"), + ("Immersive Video", "იმერსიული ვიდეო"), + ("Keying", "კეიინგი"), + ("Matte", "მატი"), + ("Noise & Grain", "ხმაური და მარცვლოვნება"), + ("Obsolete", "მოძველებული"), + ("Paint", "ხატვა"), + ("Perspective", "პერსპექტივა"), + ("Simulation", "სიმულაცია"), + ("Stylize", "სტილიზაცია"), + ("Text", "ტექსტი"), + ("Time", "დრო"), + ("Transition", "გადასვლა"), + ("Utility", "დამხმარე"), +]; + /// The 21 category folder names the generated Effect menu and Effects & Presets draw, in /// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese /// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where @@ -554,7 +896,9 @@ mod tests { for category in &categories { assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); } - for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] { + for (language, rows) in + [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES), ("ka", GEORGIAN_CATEGORIES)] + { for (english, translated) in rows { assert!(categories.contains(english), "{language} lists {english}, which is not a category"); assert!(!translated.is_empty(), "empty {language} translation for {english}"); @@ -562,6 +906,26 @@ mod tests { } } + /// Georgian has a row for every effect the zh-hans catalog has, in the same order and naming + /// the same effect, and a label for every category; repeats and renames keep their index. + #[test] + fn georgian_names_every_effect_and_category() { + let hans: Vec<(&str, &str)> = SIMPLIFIED_CHINESE.iter().map(|(id, en, _)| (*id, *en)).collect(); + let ka: Vec<(&str, &str)> = GEORGIAN.iter().map(|(id, en, _)| (*id, *en)).collect(); + assert_eq!(hans, ka, "the Georgian effect rows differ from the zh-hans ones"); + assert!(GEORGIAN.iter().all(|(_, _, translated)| !translated.is_empty()), "an empty Georgian effect name"); + for category in effects::categories() { + assert!(GEORGIAN_CATEGORIES.iter().any(|(english, _)| *english == category), "no Georgian row for category {category}"); + } + set_current("ka"); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "გაუსის გაბუნდოვნება"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "გაუსის გაბუნდოვნება 2"); + assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur"); + assert_eq!(category("Blur & Sharpen"), "გაბუნდოვნება და სიმკვეთრე"); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "გაუსის")); + set_current("en"); + } + /// A repeated effect carries the index the engine appends to the group name; a name the user /// typed in Effect Controls stays verbatim. #[test] diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a271aeb1..4ac96fd18 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -47,6 +47,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), @@ -55,6 +56,8 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + // Georgian also reads its own rows for text the other catalogs don't have yet. + "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -4200,6 +4203,959 @@ const SPANISH: &[(&str, &str)] = &[ ("Project", "Proyecto"), ]; +/// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). +const GEORGIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "იერსახის პარამეტრები…"), + ("Add to favorites", "რჩეულებში დამატება"), + ("Remove from favorites", "რჩეულებიდან ამოშლა"), + (" OK ", " OK "), + (" Delete ", " წაშლა "), + (" Open Auto-Save ", " ავტოშენახვის გახსნა "), + ("(missing)", "(აკლია)"), + ("360 View Orientation", "360° ხედის ორიენტაცია"), + ("3:2 Pulldown: {}", "3:2 Pulldown: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "სუფთა ოთახის მეთოდით შექმნილი, ღია კოდის კომპოზიტორი მოძრავი გრაფიკისა და ვიზუალური ეფექტებისთვის: ნატიურად მუშაობს macOS-ზე, Windows-სა და Linux-ზე, ასევე ბრაუზერში. ArtCraft-ის შემოქმედებითი აპების ოჯახის ნაწილი.", + ), + ("About EffectCraft", "EffectCraft-ის შესახებ"), + ("About...", "პროგრამის შესახებ..."), + ("Active", "აქტიური"), + ("Active Camera", "აქტიური კამერა"), + ("Alpha", "ალფა"), + ("Adapt Feature On Every Frame", "მახასიათებლის განახლება ყოველ კადრზე"), + ("Adaptive Sample Limit:", "ადაპტური ნიმუშების ზღვარი:"), + ("Add Animator", "ანიმატორის დამატება"), + ("Add Comment", "კომენტარის დამატება"), + ("Add Formatting", "ფორმატირების დამატება"), + ("Add Group", "ჯგუფის დამატება"), + ("Add Mirror", "სარკის დამატება"), + ("Add Selected As", "მონიშნულის დამატება როგორც"), + ("Add a comment", "კომენტარის დამატება"), + ("Add a shape, fill, stroke or path operation", "ფიგურის, შევსების, კონტურის ან ტრაექტორიის ოპერაციის დამატება"), + ("Add to Composition", "კომპოზიციაში დამატება"), + ("Add to Favorites", "რჩეულებში დამატება"), + ("Add:", "დამატება:"), + ("Adjust Exposure (stops)", "ექსპოზიციის კორექცია (საფეხურები)"), + ("Adjust composition duration to the time span of the selected layers", "კომპოზიციის ხანგრძლივობის მორგება მონიშნული შრეების დროის შუალედზე"), + ("Advanced 3D", "გაფართოებული 3D"), + ("Align Layers to:", "შრეების სწორება:"), + ("All six face cameras turn together; the scene is not changed.", "სახეების ექვსივე კამერა ერთად ბრუნავს; სცენა არ იცვლება."), + ("Anchor:", "მიმაგრება:"), + ("Aperture", "დიაფრაგმა"), + ("Appearance Mode", "იერსახის რეჟიმი"), + ("Appearance Mode: {}", "იერსახის რეჟიმი: {}"), + ("Apply Dimensions:", "განზომილებების გამოყენება:"), + ("Apply Motion To:", "მოძრაობის გამოყენება:"), + ("Arrange", "დალაგება"), + ("Assign", "მინიჭება"), + ("Auto", "ავტომატური"), + ("Auto-saves", "ავტოშენახვები"), + ("Back", "უკან"), + ("Background Color:", "ფონის ფერი:"), + ("Blend Mode:", "შერევის რეჟიმი:"), + ("Blending Mode", "შერევის რეჟიმი"), + ("Blur", "გაბუნდოვნება"), + ("Blur Level", "გაბუნდოვნების დონე"), + ("Boundary", "საზღვარი"), + ("Browser Storage", "ბრაუზერის მეხსიერება"), + ("Browse…", "არჩევა…"), + ("Brush Dynamics", "ფუნჯის დინამიკა"), + ("Caching frames…", "კადრების ქეშირება…"), + ("Cancel", "გაუქმება"), + ("Casts Shadows", "ჩრდილს აგდებს"), + ("Change Renderer…", "რენდერერის შეცვლა…"), + ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), + ("Channel", "არხი"), + ("Channel:", "არხი:"), + ("Chapter:", "თავი:"), + ("Choose...", "არჩევა..."), + ("Classic 3D", "კლასიკური 3D"), + ("Clear", "გასუფთავება"), + ("Clear list", "სიის გასუფთავება"), + ("Click a key to see its commands.", "დაწკაპეთ კლავიშზე მისი ბრძანებების სანახავად."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "დაწკაპეთ ზოლის ზემოთ გაუმჭვირვალობის გადასვლის წერტილის დასამატებლად, ქვემოთ — ფერის გადასვლის წერტილის დასამატებლად.", + ), + ("Click adds a point (½ height = no change)", "დაწკაპუნება ამატებს წერტილს (½ სიმაღლე = ცვლილების გარეშე)"), + ("Click adds a point; drag one off to remove", "დაწკაპუნება ამატებს წერტილს; მის მოსაშორებლად გადაათრიეთ გარეთ"), + ("Click to make this viewer active", "დაწკაპეთ ამ დამთვალიერებელის გასააქტიურებლად"), + ("Click to reveal the expression in the timeline", "დაწკაპეთ ექსპრესიის დროის ხაზზე საჩვენებლად"), + ("Click to show the active composition here", "დაწკაპეთ აქტიური კომპოზიციის აქ საჩვენებლად"), + ("Click, then click in the Composition panel", "დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Clone Options", "კლონირების პარამეტრები"), + ("Clone Source Overlay:", "კლონირების წყაროს გადაფარვა:"), + ("Close", "დახურვა"), + ("Close Panel", "პანელის დახურვა"), + ("Close tutorial", "გაკვეთილის დახურვა"), + ("Color", "ფერი"), + ("Color Depth: {}", "ფერის სიღრმე: {}"), + ("Color:", "ფერი:"), + ("Colour map", "ფერების რუკა"), + ("Columns", "სვეტები"), + ("Comment:", "კომენტარი:"), + ("Community", "საზოგადოება"), + ("Composition", "კომპოზიცია"), + ("Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("Composition Name:", "კომპოზიციის სახელი:"), + ("Composition Settings ▸ 3D Renderer", "კომპოზიციის პარამეტრები ▸ 3D რენდერერი"), + ("Composition Settings…", "კომპოზიციის პარამეტრები…"), + ("Composition marker bin: drag a marker to the time ruler", "კომპოზიციის მარკერების ბინი: გადაათრიეთ მარკერი დროის სახაზავზე"), + ("Cone Angle", "კონუსის კუთხე"), + ("Cone Feather", "კონუსის კიდეების შერბილება"), + ("Constrain Proportions", "პროპორციების შენარჩუნება"), + ("Continuous (Lock Outgoing to Incoming)", "უწყვეტი (გამავალის მიბმა შემავალზე)"), + ("Cue Point", "მინიშნების წერტილი"), + ("Current Render", "მიმდინარე რენდერი"), + ("Dark", "ბნელი"), + ("Dark Theme", "ბნელი თემა"), + ("Darker", "უფრო მუქი"), + ("Default Output:", "ნაგულისხმევი გამოტანა:"), + ("Default: {}", "ნაგულისხმევი: {}"), + ("Defaults", "ნაგულისხმევი"), + ("Delete", "წაშლა"), + ("Delete Selected Points", "მონიშნული წერტილების წაშლა"), + ("Delete selected project items", "პროექტის მონიშნული ელემენტების წაშლა"), + ("Delete this template", "ამ შაბლონის წაშლა"), + ("Depth of Field", "ველის სიღრმე"), + ("Discord", "Discord"), + ("Distribute Layers:", "შრეების განაწილება:"), + ("Don't Recover", "არ აღადგინო"), + ("Don't Save", "არ შეინახო"), + ("Double-click a layer to open it in the Layer panel", "ორჯერ დაწკაპეთ შრეზე მის შრის პანელში გასახსნელად"), + ("Double-click footage in the Project panel to open it here", "ორჯერ დაწკაპეთ ვიდეომასალაზე პროექტის პანელში მის აქ გასახსნელად"), + ("Double-click to edit the comment", "ორჯერ დაწკაპეთ კომენტარის რედაქტირებისთვის"), + ("Download", "ჩამოტვირთვა"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "გადმოათრიეთ თვისებები დროის ხაზიდან,\nან გამოიყენეთ ანიმაცია > თვისების დამატება ძირითად გრაფიკაში.", + ), + ("Drag to change", "გადაათრიეთ შესაცვლელად"), + ("Drag to create a guide", "გადაათრიეთ დამხმარე ხაზის შესაქმნელად"), + ("Drag to slip the source", "გადაათრიეთ წყაროს გადასაწევად"), + ("Drop Frame", "კადრების გამოტოვებით"), + ("Duplicate", "დუბლირება"), + ("Duration:", "ხანგრძლივობა:"), + ("Edit Gradient…", "გრადიენტის რედაქტირება…"), + ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), + ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), + ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), + ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), + ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), + ("Effects: {}", "ეფექტები: {}"), + ("Empty folder", "ცარიელი საქაღალდე"), + ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), + ("Enable Expression", "ექსპრესიის ჩართვა"), + ("Enhance", "გაუმჯობესება"), + ("Essential Properties — {}", "ძირითადი თვისებები — {}"), + ("Event", "მოვლენა"), + ("Export Template…", "შაბლონის ექსპორტი…"), + ("Export...", "ექსპორტი..."), + ("Expression Language Menu", "ექსპრესიების ენის მენიუ"), + ("Expression pick whip: drag onto a property", "ექსპრესიის ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Expression:", "ექსპრესია:"), + ("Eyedropper: click, then click in the Composition panel", "პიპეტი: დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Falloff", "მილევა"), + ("Falloff Distance", "მილევის მანძილი"), + ("Favorites", "რჩეულები"), + ("Field Render: {}", "ნახევარკადრების რენდერი: {}"), + ("Figures", "ციფრები"), + ("Fill Target", "შევსების სამიზნე"), + ("Finished", "დასრულდა"), + ("Flowchart: {}", "ბლოკ-სქემა: {}"), + ("Focus Distance", "ფოკუსური მანძილი"), + ("Follow Feature Rotation and Scale", "მახასიათებლის შემობრუნებისა და მასშტაბის მიდევნება"), + ("Footage: {}", "ვიდეომასალა: {}"), + ("Format: {}", "ფორმატი: {}"), + ("Frame Aspect Ratio: {}", "კადრის თანაფარდობა: {}"), + ("Frame Blending: {}", "კადრების შერევა: {}"), + ("Frame Rate:", "კადრების სიხშირე:"), + ("Frame Render Time {}ms", "კადრის რენდერის დრო {}ms"), + ("Frame Target:", "კადრის სამიზნე:"), + ("Freeze", "გაყინვა"), + ("Grab bag", "სხვადასხვა"), + ("Gradient Editor", "გრადიენტის რედაქტორი"), + ("Grid", "ბადე"), + ("Guide Layers: {}", "დამხმარე შრეები: {}"), + ("Guides", "დამხმარე ხაზები"), + ("Height:", "სიმაღლე:"), + ("Hide This", "ამის დამალვა"), + ("Histogram", "ჰისტოგრამა"), + ("Hold In Place", "ადგილზე დაფიქსირება"), + ("Home", "საწყისი ეკრანი"), + ("If Confidence is Below", "თუ სანდოობა ნაკლებია"), + ("Import", "იმპორტი"), + ("Import and Add to Composition", "იმპორტი და კომპოზიციაში დამატება"), + ("Import...", "იმპორტი..."), + ("Imported media", "იმპორტირებული მედია"), + ("Influence:", "გავლენა:"), + ("Install from File…", "ფაილიდან დაყენება…"), + ("Intensity", "ინტენსივობა"), + ("Inverted", "შებრუნებული"), + ("Join the ArtCraft community on Discord", "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე"), + ("Key: {}", "გასაღები: {}"), + ("LEARN", "სწავლა"), + ("Label", "იარლიყი"), + ("Label Colors and Names:", "იარლიყების ფერები და სახელები:"), + ("Label:", "იარლიყი:"), + ("Latest auto-save: {}", "ბოლო ავტოშენახვა: {}"), + ("Layer", "შრე"), + ("Layer controls", "შრის მართვის ელემენტები"), + ("Layer {}", "შრე {}"), + ("Layer:", "შრე:"), + ("Layer: {}", "შრე: {}"), + ("Learn", "სწავლა"), + ("Leave all attributes in \"{}\"", "ყველა ატრიბუტის დატოვება „{}“-ში"), + ("Licence: {}", "ლიცენზია: {}"), + ("Ligatures", "ლიგატურები"), + ("Light", "ღია"), + ("Light Theme", "ღია თემა"), + ("Light Type", "სინათლის ტიპი"), + ("Lighter", "უფრო ღია"), + ("Link Selected Property", "მონიშნული თვისების დაკავშირება"), + ("Location:", "პოზიცია:"), + ("Lock Aspect Ratio to {}", "თანაფარდობის ჩაკეტვა: {}"), + ("Lock to Zoom", "მასშტაბზე მიბმა"), + ("Locked: {} — {}", "ჩაკეტილი: {} — {}"), + ("Log:", "ჟურნალი:"), + ("Loop (Trace Path)", "მარყუჟი (ტრაექტორიის გავლა)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • შრიფტები: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "გადიდების კოეფიციენტის მენიუ"), + ("Make Comp Size", "კომპოზიციის ზომაზე მორგება"), + ("Markers", "მარკერები"), + ("Masks", "ნიღბები"), + ("Maximize Panel", "პანელის გაშლა"), + ("Media Replacement", "მედიის ჩანაცვლება"), + ("Mesh:", "ბადე:"), + ("Diameter:", "დიამეტრი:"), + ("Method Used: {} Average Error: {} pixels", "გამოყენებული მეთოდი: {} საშუალო ცდომილება: {} პიქსელი"), + ("Models folder: {}", "მოდელების საქაღალდე: {}"), + ("Modifiers:", "მოდიფიკატორები:"), + ("More", "მეტი"), + ("More ArtCraft apps", "ArtCraft-ის სხვა აპლიკაციები"), + ("More tutorials", "მეტი გაკვეთილი"), + ("Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("Motion Blur: {}", "მოძრაობის გაბუნდოვნება: {}"), + ("Motion Sketch: drag to record", "მოძრაობის ესკიზი: გადაათრიეთ ჩასაწერად"), + ("Move Down", "ჩამოწევა"), + ("Move Up", "აწევა"), + ("Move to Project Root", "პროექტის ძირში გადატანა"), + ("Name", "სახელი"), + ("Name:", "სახელი:"), + ("Navigation", "ნავიგაცია"), + ("New", "ახალი"), + ("New Comp from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Replace with Precomposition", "ჩადგმული კომპოზიციით ჩანაცვლება"), + ("New Duration:", "ახალი ხანგრძლივობა:"), + ("New composition name:", "ახალი კომპოზიციის სახელი:"), + ("New from Render Queue item:", "ახალი რენდერის რიგის ელემენტიდან:"), + ("New from Template", "შაბლონიდან შექმნა"), + ("Next", "შემდეგი"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "ყოჩაღ. აირჩიეთ სხვა გაკვეთილი საწყისი ეკრანის „სწავლის“ ჩანართზე."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "VR გარემო ჯერ არ არის. შექმენით: კომპოზიცია ▸ VR ▸ VR გარემოს შექმნა…", + ), + ("No audio layer selected", "აუდიოშრე არ არის მონიშნული"), + ("No background jobs running", "ფონური ამოცანები არ მიმდინარეობს"), + ("No composition markers", "კომპოზიციის მარკერები არ არის"), + ("No contributor data was built into this copy.", "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული."), + ( + "No device list from this host; the system default output is used.", + "ამ ჰოსტიდან მოწყობილობების სია მიუწვდომელია; გამოიყენება სისტემის ნაგულისხმევი გამოტანა.", + ), + ("No layer selected", "შრე არ არის მონიშნული"), + ("No matching effects or presets", "შესაბამისი ეფექტები ან პრესეტები არ მოიძებნა"), + ("No matching fonts", "შესაბამისი შრიფტები არ მოიძებნა"), + ("Variable fonts only", "მხოლოდ ცვლადი შრიფტები"), + ("No model credits were built into this copy.", "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული."), + ("No nested compositions", "ჩადგმული კომპოზიციები არ არის"), + ("No shortcut", "მალსახმობის გარეშე"), + ("No unlocked layer selected", "განბლოკილი შრე არ არის მონიშნული"), + ("No video", "ვიდეოს გარეშე"), + ("None", "არცერთი"), + ("Not applied: tick it in the list to add it.", "არ არის გამოყენებული: დასამატებლად მონიშნეთ სიაში."), + ("Not assigned", "არ არის მინიჭებული"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "შენიშვნა: ჩრდილს აგდებს მხოლოდ ის შრე, რომელსაც ჩართული აქვს „ჩრდილს აგდებს“."), + ("Notify", "შეტყობინება"), + ("OK", "OK"), + ("Offset:", "წანაცვლება:"), + ("On ({}) stop:", "ჩართვისას ({}) გაჩერება:"), + ("One-Node Camera", "ერთკვანძიანი კამერა"), + ("Opacity:", "გაუმჭვირვალობა:"), + ("Open Composition", "კომპოზიციის გახსნა"), + ("used once", "გამოყენებულია ერთხელ"), + ("used {} times", "გამოყენებულია {}-ჯერ"), + ("Compositions that use this item", "კომპოზიციები, რომლებიც ამ ელემენტს იყენებს"), + ("Open Last Saved Project", "ბოლოს შენახული პროექტის გახსნა"), + ("Open New Composition", "ახალი კომპოზიციის გახსნა"), + ("Open Output", "გამოტანის გახსნა"), + ("Open a composition to build Essential Graphics", "გახსენით კომპოზიცია ძირითადი გრაფიკის ასაგებად"), + ("Open a composition to see its scopes", "გახსენით კომპოზიცია მისი სკოპების სანახავად"), + ("Open a composition to track motion.", "გახსენით კომპოზიცია მოძრაობის ტრეკინგისთვის."), + ("Opus Tuning: {}", "Opus-ის მორგება: {}"), + ("Original Duration:", "საწყისი ხანგრძლივობა:"), + ("Output Cycle: {}", "გამოტანის ციკლი: {}"), + ("Output To:", "გამოტანის ადგილი:"), + ("Overlay", "გადაფარვა"), + ("Parameter Name:", "პარამეტრის სახელი:"), + ("Parent pick whip: drag onto a layer", "მშობლის ბმის სპირალი: გადაათრიეთ შრეზე"), + ("Pixel Aspect Ratio:", "პიქსელის გვერდების თანაფარდობა:"), + ("Playing on Video Preview", "იკვრება ვიდეოს წინასწარ ნახვაზე"), + ("Post-Render Action: {}", "რენდერის შემდგომი მოქმედება: {}"), + ("Preserve frame rate when nested or in render queue", "კადრების სიხშირის შენარჩუნება ჩადგმისას ან რენდერის რიგში"), + ("Preserve resolution when nested", "გარჩევადობის შენარჩუნება ჩადგმისას"), + ("Preset", "პრესეტი"), + ("Preset:", "პრესეტი:"), + ("Presets", "პრესეტები"), + ("Previous", "წინა"), + ("Primary:", "ძირითადი:"), + ("Process Before Match", "დამუშავება შეთანხმებამდე"), + ("Profile: {}", "პროფილი: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "პროექტის ფერის სიღრმე: დაწკაპეთ 8/16/32 bpc-ის გადასართავად, Alt-დაწკაპუნება — პროექტის პარამეტრები", + ), + ("Project: {}", "პროექტი: {}"), + ("Projects", "პროექტები"), + ("Property pick whip: drag onto a property", "თვისების ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Protected Region", "დაცული არე"), + ("Proxy Use: {}", "პროქსის გამოყენება: {}"), + ("Push All to Comp", "ყველაფრის კომპოზიციაში გადატანა"), + ("RGB", "RGB"), + ("Radius", "რადიუსი"), + ("Rate Control: {}", "სიჩქარის კონტროლი: {}"), + ("Recent", "ბოლოდროინდელი"), + ("Recording", "ჩაწერა"), + ("Remove", "წაშლა"), + ("Rename", "სახელის შეცვლა"), + ("Render: {} ms • UI {} fps{}", "რენდერი: {} ms • UI {} fps{}"), + ("Renderer:", "რენდერერი:"), + ("Request Persistent Storage", "მუდმივი მეხსიერების მოთხოვნა"), + ("Reset", "ჩამოყრა"), + ("Reset Labels", "იარლიყების ჩამოყრა"), + ("Reset Preset", "პრესეტის ჩამოყრა"), + ("Reset to Default", "ნაგულისხმევზე დაბრუნება"), + ("Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("Resolution/Down Sample Factor Popup", "გარჩევადობის/დისკრეტიზაციის კოეფიციენტის მენიუ"), + ("Restore Panel Size", "პანელის ზომის აღდგენა"), + ("Reverse the order", "რიგის შებრუნება"), + ("Revert All", "ყველაფრის დაბრუნება"), + ("Roman Hanging Punctuation", "ლათინური გამოწეული პუნქტუაცია"), + ("Roving:", "მცურავი:"), + ("Ruler zero point (drag to move, double-click to reset)", "სახაზავის ნულოვანი წერტილი (გადაათრიეთ გადასაადგილებლად, ორჯერ დაწკაპეთ ჩამოსაყრელად)"), + ("Rulers", "სახაზავები"), + ("Run", "გაშვება"), + ("Sample Format: {}", "ნიმუშის ფორმატი: {}"), + ("Samples Per Frame:", "ნიმუშები კადრზე:"), + ("Save from #{}", "შენახვა #{}-დან"), + ("Saved {}", "შენახულია {}"), + ("Script… (⌘/Ctrl+Enter runs)", "სკრიპტი… (⌘/Ctrl+Enter უშვებს)"), + ("Search commands or shortcuts", "ბრძანებების ან მალსახმობების ძიება"), + ("Select", "მონიშვნა"), + ("Select a command to change its shortcut.", "მონიშნეთ ბრძანება მისი მალსახმობის შესაცვლელად."), + ("Select a layer to see its effects", "მონიშნეთ შრე მისი ეფექტების სანახავად"), + ("Select a layer to see its properties", "მონიშნეთ შრე მისი თვისებების სანახავად"), + ("({}) Selected Objects", "({}) მონიშნული ობიექტი"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "მონიშნეთ ნიღბიანი ან ფიგურის ტრაექტორიის მქონე შრე (ან ტრაექტორიის თვისება), ან მარიონეტის სამაგრები.", + ), + ("Select a text layer", "მონიშნეთ ტექსტური შრე"), + ("Select an item in the Project panel", "მონიშნეთ ელემენტი პროექტის პანელში"), + ("Select animated properties to show them in the Graph Editor", "მონიშნეთ ანიმირებული თვისებები გრაფიკების რედაქტორში საჩვენებლად"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "მონიშნეთ წამყვანი სამაგრი, შემდეგ Shift-დაწკაპუნებით მონიშნეთ მიმყოლი სამაგრები (თმა, ქსოვილი, კუდები)", + ), + ("Set Ground Plane and Origin", "მიწის სიბრტყისა და სათავის დაყენება"), + ("Set Paint Colors to Black and White (D)", "ხატვის ფერების შავ-თეთრზე დაყენება (D)"), + ("Settings", "პარამეტრები"), + ("Settings Name:", "პარამეტრების სახელი:"), + ("Shadow Darkness", "ჩრდილის სიმუქე"), + ("Shadow Diffusion", "ჩრდილის გაბნევა"), + ("Shadows", "ჩრდილები"), + ("Shape…", "ფორმა…"), + ("Show", "ჩვენება"), + ("Show Layer Name", "შრის სახელის ჩვენება"), + ("Show Source Name", "წყაროს სახელის ჩვენება"), + ("Show me", "მაჩვენე"), + ("Show:", "ჩვენება:"), + ("Shutter Angle:", "ობტურატორის კუთხე:"), + ("Shutter Phase:", "ობტურატორის ფაზა:"), + ("Size", "ზომა"), + ("Skip", "გამოტოვება"), + ("Snapping", "მიბმა"), + ("Snapping options", "მიბმის პარამეტრები"), + ("Snapshot", "მომენტალური ასლი"), + ("Solo Supported Properties", "მხარდაჭერილი თვისებების სოლო"), + ("Solo Switches: {}", "სოლოს გადამრთველები: {}"), + ("Sort", "სორტირება"), + ("Spacing:", "ინტერვალი:"), + ("Spatial Interpolation:", "სივრცითი ინტერპოლაცია:"), + ("Speed:", "სიჩქარე:"), + ("Start Timecode:", "საწყისი თაიმკოდი:"), + ("Start: {} Duration: {}", "დასაწყისი: {} ხანგრძლივობა: {}"), + ("Step {} of {}: {}", "ნაბიჯი {} / {}: {}"), + ("Stop", "შეწყვეტა"), + ("Stop {} at {}°", "გადასვლის წერტილი {} — {}°"), + ("Stored in: {}", "შენახულია: {}"), + ("Stretch", "გაწელვა"), + ("Stretch Factor:", "გაწელვის კოეფიციენტი:"), + ("Stylistic Sets", "სტილისტური ნაკრებები"), + ("Subpixel Positioning", "სუბპიქსელური პოზიციონირება"), + ("Swap Colors", "ფერების გაცვლა"), + ("Swap Paint Colors (X)", "ხატვის ფერების გაცვლა (X)"), + ("Sync with System", "სისტემასთან სინქრონიზაცია"), + ("System Default", "სისტემის ნაგულისხმევი"), + ("Table", "ცხრილი"), + ("Template name", "შაბლონის სახელი"), + ("Template: {}\n{}", "შაბლონი: {}\n{}"), + ("Temporal Interpolation:", "დროითი ინტერპოლაცია:"), + ("The Media Browser is not available here (use File ▸ Import)", "მედიის ბრაუზერი აქ მიუწვდომელია (გამოიყენეთ ფაილი ▸ იმპორტი)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "OpenEXR შრე წითლად, მწვანედ, ლურჯად და ალფად"), + ("The render queue is empty.", "რენდერის რიგი ცარიელია."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "ამ მეზობელ საკვანძო კადრებს შორის ინტერპოლაცია შეუძლებელია, რადგან მათ გრადიენტის გადასვლის წერტილების განსხვავებული რაოდენობა აქვთ.", + ), + ("This ScriptUI panel has closed", "ეს ScriptUI პანელი დაიხურა"), + ("This change will create a new solid for this layer.", "ეს ცვლილება ამ შრისთვის ახალ სოლიდს შექმნის."), + ("Time", "დრო"), + ("Time Stretch…", "დროში გაწელვა…"), + ("Time:", "დრო:"), + ("Time: {}", "დრო: {}"), + ("Title/Action Safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Title/action safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Toggle Switches / Modes", "გადამრთველების / რეჟიმების გადართვა"), + ("Track Name:", "ტრეკის სახელი:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "გაწვრთნილი მოდელები ხელმისაწვდომია დესკტოპის აპში; ბრაუზერი კლასიკურ ძრავს იყენებს.", + ), + ("Transform", "გარდაქმნა"), + ("Transparency grid", "გამჭვირვალობის ბადე"), + ("Tutorial complete", "გაკვეთილი დასრულდა"), + ("Two-Node Camera", "ორკვანძიანი კამერა"), + ("Type", "ტიპი"), + ("Type a command or effect…", "აკრიფეთ ბრძანება ან ეფექტი…"), + ("URL:", "URL:"), + ("Undock Panel", "პანელის მოხსნა"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move Panel to Main Window", "პანელის მთავარ ფანჯარაში გადატანა"), + ("Unfreeze", "გაყინვის მოხსნა"), + ("Unlink {}", "{}-ის კავშირის გაწყვეტა"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "გამოიყენეთ ეს ვარიანტი მონიშნული შრეების ერთად ახალ შუალედურ კომპოზიციაში მოსათავსებლად.", + ), + ("VR Environments", "VR გარემოები"), + ("Value:", "მნიშვნელობა:"), + ("Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("Video Codec: {}", "ვიდეო კოდეკი: {}"), + ("View:", "ხედი:"), + ("Welcome to EffectCraft", "მოგესალმებათ EffectCraft"), + ("Width:", "სიგანე:"), + ("You can undo this.", "ამის დაბრუნება შეგიძლიათ."), + ("You can't undo this.", "ამის დაბრუნება შეუძლებელია."), + ("Your changes will be lost if you don't save them.", "თუ ცვლილებებს არ შეინახავთ, ისინი დაიკარგება."), + ("Zoom", "მასშტაბი"), + ("built-in", "ჩაშენებული"), + ("frames per second", "კადრი წამში"), + ("template name", "შაბლონის სახელი"), + ("{} contributors · {} commits", "მონაწილე: {} · კომიტი: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB 8bpc კადრზე"), + ("{} — coming soon", "{} — მალე"), + ("{} — double-click to open, {}-click to reveal", "{} — ორჯერ დაწკაპეთ გასახსნელად, {}-დაწკაპუნება საჩვენებლად"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-ით გადაათრიეთ სამაგრი მისი მოძრაობის ჩასაწერად"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} გამოიყენება:"), + ("3D Reference Axes", "3D საორიენტაციო ღერძები"), + ("Affect all layers that use this solid", "ზემოქმედება ყველა შრეზე, რომელიც ამ სოლიდს იყენებს"), + ("Choose grid and guide options", "ბადისა და დამხმარე ხაზების პარამეტრების არჩევა"), + ("Colorize", "გაფერადება"), + ("Custom", "მორგებული"), + ("Custom...", "მორგებული..."), + ("Fit", "ჩატევა"), + ("Fit up to 100%", "ჩატევა 100%-მდე"), + ("Full", "სრული"), + ("Half", "ნახევარი"), + ("Only this layer uses this solid.", "ამ სოლიდს მხოლოდ ეს შრე იყენებს."), + ("Proportional Grid", "პროპორციული ბადე"), + ("Quarter", "მეოთხედი"), + ("Region of Interest", "ინტერესის არე"), + ("Reset Exposure", "ექსპოზიციის ჩამოყრა"), + ("Show Channel and Color Management Settings", "არხისა და ფერთა მართვის პარამეტრების ჩვენება"), + ("Show Snapshot (hold)", "მომენტალური ასლის ჩვენება (დაჭერით)"), + ("Take Snapshot", "მომენტალური ასლის გადაღება"), + ("Third", "მესამედი"), + ("Toggle Mask and Shape Path Visibility", "ნიღბისა და ფიგურის ტრაექტორიის ხილვადობის გადართვა"), + ("Toggle Transparency Grid", "გამჭვირვალობის ბადის გადართვა"), + ("16 bits per channel", "16 ბიტი არხზე"), + ("32 bits per channel (float)", "32 ბიტი არხზე (მცურავი)"), + ("8 bits per channel", "8 ბიტი არხზე"), + ("Adaptive Resolution", "ადაპტური გარჩევადობა"), + ("Adobe-style built-in", "ჩაშენებული, Adobe-ს სტილის"), + ("Blue", "ლურჯი"), + ("Current Settings", "მიმდინარე პარამეტრები"), + ("Disable Per-character 3D", "თითოეული ასოს 3D-ს გამორთვა"), + ("Draft", "მონახაზი"), + ("Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("Fast Draft", "სწრაფი მონახაზი"), + ("Fast Previews (Draft 3D, Faster Effects)", "სწრაფი წინასწარი ნახვა (3D მონახაზი, უფრო სწრაფი ეფექტები)"), + ("Green", "მწვანე"), + ("Import Image Sequence", "გამოსახულებების თანმიმდევრობის იმპორტი"), + ("Import PDF / Illustrator File", "PDF / Illustrator ფაილის იმპორტი"), + ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), + ("Interpret Unlabeled Alpha As", "არამონიშნული ალფას ინტერპრეტაცია როგორც"), + ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), + ("Mercury GPU Acceleration ({})", "Mercury GPU აჩქარება ({})"), + ("Mercury Software Only", "მხოლოდ Mercury Software"), + ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), + ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), + ("OpenGL (compatibility)", "OpenGL (თავსებადობა)"), + ("Opus Bitrate: {} kbps", "Opus-ის ბიტრეიტი: {} kbps"), + ("RGB Straight", "RGB Straight"), + ("Red", "წითელი"), + ("Tone map", "ტონური რუკა"), + ("Use In-Window Menu Bar on macOS", "ფანჯრის შიდა მენიუს ზოლის გამოყენება macOS-ზე"), + ("Wireframe", "კარკასი"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (ნაგულისხმევი)"), + ("New Project", "ახალი პროექტი"), + ("Open Project…", "პროექტის გახსნა…"), + ("Templates", "შაბლონები"), + ("EffectCraft home page", "EffectCraft-ის საწყისი გვერდი"), + ("EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("Motion graphics and visual effects · version {}", "მოძრავი გრაფიკა და ვიზუალური ეფექტები · ვერსია {}"), + ("New Composition", "ახალი კომპოზიცია"), + ("Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), + ("Import Footage…", "ვიდეომასალის იმპორტი…"), + ("Filter recent files", "ბოლო ფაილების გაფილტვრა"), + ("NAME", "სახელი"), + ("OPENED", "გახსნილია"), + ("SIZE", "ზომა"), + ("KIND", "სახეობა"), + ("Your recent files will show here", "ბოლოდროინდელი ფაილები აქ გამოჩნდება"), + ("Create a new project, or open the demo project to look around.", "შექმენით ახალი პროექტი, ან გახსენით სადემონსტრაციო პროექტი გასაცნობად."), + ("No recent files match the filter", "ფილტრს ბოლოდროინდელი ფაილები არ შეესაბამება"), + ("Try another name or folder.", "სცადეთ სხვა სახელი ან საქაღალდე."), + ("Just now", "ახლახან"), + ("Yesterday", "გუშინ"), + ("{} minute ago", "{} წუთის წინ"), + ("{} minutes ago", "{} წუთის წინ"), + ("{} hour ago", "{} საათის წინ"), + ("{} hours ago", "{} საათის წინ"), + ("{} day ago", "{} დღის წინ"), + ("{} days ago", "{} დღის წინ"), + ("EffectCraft project", "EffectCraft-ის პროექტი"), + ("After Effects project", "After Effects-ის პროექტი"), + ("Lottie animation", "Lottie ანიმაცია"), + ("Project", "პროექტი"), + ("General", "ზოგადი"), + ("Language", "ენა"), + ("Levels of Undo", "დაბრუნების დონეები"), + ("Path Point and Handle Size", "ტრაექტორიის წერტილისა და სახელურის ზომა"), + ("Recent Projects Shown", "ნაჩვენები ბოლო პროექტები"), + ("Options", "პარამეტრები"), + ("Show Tool Tips", "მინიშნებების ჩვენება"), + ("Create Layers at Composition Start Time", "შრეების შექმნა კომპოზიციის დაწყების დროზე"), + ("Switches Affect Nested Comps", "გადამრთველები მოქმედებს ჩადგმულ კომპოზიციებზე"), + ("Default Spatial Interpolation to Linear", "ნაგულისხმევი სივრცითი ინტერპოლაცია: წრფივი"), + ( + "Preserve Constant Vertex and Feather Point Count when Editing Masks", + "ნიღბების რედაქტირებისას წვეროებისა და შერბილების წერტილების რაოდენობის შენარჩუნება", + ), + ("Synchronize Time of All Related Items", "ყველა დაკავშირებული ელემენტის დროის სინქრონიზაცია"), + ("Expression Pick Whip Writes Compact English", "ექსპრესიის ბმის სპირალი წერს კომპაქტურ ინგლისურს"), + ("Create Split Layers Above Original Layer", "დაყოფილი შრეების შექმნა საწყისი შრის ზემოთ"), + ("Use System Color Picker", "სისტემის ფერის ამომრჩევის გამოყენება"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft ყოველთვის საკუთარ ფერის ამომრჩევს იყენებს (ერთნაირს ყველა პლატფორმაზე და ვებში).", + ), + ("Startup & Repair", "გაშვება და აღდგენა"), + ("Show Home Screen When Launching", "საწყისი ეკრანის ჩვენება გაშვებისას"), + ("Show Home Screen When Opening a Project", "საწყისი ეკრანის ჩვენება პროექტის გახსნისას"), + ("Offer to Open the Latest Auto-Save After a Crash", "ავარიის შემდეგ ბოლო ავტოშენახვის გახსნის შეთავაზება"), + ("Window Graphics", "ფანჯრის გრაფიკა"), + ("Automatic", "ავტომატური"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ფანჯრის გრაფიკა მოქმედებს შემდეგი გაშვებიდან. თუ გრაფიკის დრაივერი EffectCraft-ს ფანჯრის გახსნისას აჩერებს, შემდეგი გაშვება OpenGL-ზე გადაერთვება.", + ), + ("Repair", "აღდგენა"), + ("Reset Settings", "პარამეტრების ჩამოყრა"), + ("Reset Keyboard Shortcuts", "კლავიატურის მალსახმობების ჩამოყრა"), + ("Empty Disk Cache", "დისკის ქეშის გასუფთავება"), + ("New Project Loads Template", "ახალი პროექტი შაბლონს ტვირთავს"), + ("Template Project", "შაბლონი პროექტი"), + ("Auto-Save", "ავტოშენახვა"), + ("Automatically Save Projects", "პროექტების ავტომატური შენახვა"), + ("Save Every", "შენახვა ყოველ"), + ("Minutes", "წუთში"), + ("Maximum Project Versions", "პროექტის ვერსიების მაქსიმალური რაოდენობა"), + ("Auto-Save Location", "ავტოშენახვის მდებარეობა"), + ("Next to Project", "პროექტის გვერდით"), + ("Custom Location", "სხვა მდებარეობა"), + ("Save When Starting Render Queue", "შენახვა რენდერის რიგის დაწყებისას"), + ("Save Now", "ახლავე შენახვა"), + ("Motion Path", "მოძრაობის ტრაექტორია"), + ("All Keyframes", "ყველა საკვანძო კადრი"), + ("No Motion Path", "მოძრაობის ტრაექტორიის გარეშე"), + ("No More Than (Seconds)", "არაუმეტეს (წამი)"), + ("No More Than (Keyframes)", "არაუმეტეს (საკვანძო კადრი)"), + ("Seconds", "წამი"), + ("Keyframes", "საკვანძო კადრები"), + ("Display", "ეკრანი"), + ("Show Rendering Progress in Info Panel and Flowchart", "რენდერის პროგრესის ჩვენება ინფორმაციის პანელსა და ბლოკ-სქემაში"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "კომპოზიციის, შრისა და ვიდეომასალის პანელების აპარატული აჩქარება"), + ("Panels are always drawn by the GPU (wgpu).", "პანელებს ყოველთვის GPU ხატავს (wgpu)."), + ("Previews", "წინასწარი ნახვა"), + ("Adaptive Resolution Limit", "ადაპტური გარჩევადობის ზღვარი"), + ("Show Internal Wireframes", "შიდა კარკასების ჩვენება"), + ("Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბირების ხარისხი"), + ("Faster", "უფრო სწრაფი"), + ("More Accurate", "უფრო ზუსტი"), + ("Display Color Space", "ეკრანის ფერთა სივრცე"), + ("GPU Information...", "ინფორმაცია GPU-ს შესახებ..."), + ("Appearance", "იერსახე"), + ("Brightness", "სიკაშკაშე"), + ("UI Scale", "ინტერფეისის მასშტაბი"), + ("Labels and Colors", "იარლიყები და ფერები"), + ("Use Label Color for Layer Handles and Paths", "იარლიყის ფერის გამოყენება შრის სახელურებისა და ტრაექტორიებისთვის"), + ("Use Label Color for Related Tabs", "იარლიყის ფერის გამოყენება დაკავშირებული ჩანართებისთვის"), + ("Cycle Mask Colors", "ნიღბის ფერების მონაცვლეობა"), + ("Use Gradients", "გრადიენტების გამოყენება"), + ("Menu Bar", "მენიუს ზოლი"), + ("Grids & Guides", "ბადე და დამხმარე ხაზები"), + ("Gridline Every", "ბადის ხაზი ყოველ"), + ("Subdivisions", "ქვედანაყოფები"), + ("Style", "სტილი"), + ("Lines", "ხაზები"), + ("Dashed Lines", "წყვეტილი ხაზები"), + ("Dots", "წერტილები"), + ("Horizontal", "ჰორიზონტალური"), + ("Vertical", "ვერტიკალური"), + ("Safe Margins", "უსაფრთხო ზღვრები"), + ("Action-safe", "მოქმედების უსაფრთხო არე"), + ("Title-safe", "ტიტრების უსაფრთხო არე"), + ("Labels", "იარლიყები"), + ("Text Engine", "ტექსტის ძრავა"), + ("Latin", "ლათინური"), + ("South Asian and Middle Eastern", "სამხრეთაზიური და ახლო აღმოსავლური"), + ("Show Font Preview", "შრიფტის წინასწარი ნახვის ჩვენება"), + ("Number of Recent Fonts to Display", "საჩვენებელი ბოლო შრიფტების რაოდენობა"), + ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), + ("Still Footage", "უძრავი ვიდეომასალა"), + ("Length of Composition", "კომპოზიციის სიგრძე"), + ("Duration (Seconds)", "ხანგრძლივობა (წამი)"), + ("Still Duration", "უძრავი კადრის ხანგრძლივობა"), + ("Sequence Footage", "სეკვენციური ვიდეომასალა"), + ("Report Missing Frames", "გამოტოვებული კადრების შეტყობინება"), + ("Automatically Reload Footage", "ვიდეომასალის ავტომატური ხელახლა ჩატვირთვა"), + ("Off", "გამორთული"), + ("Non-Sequence Footage", "არასეკვენციური ვიდეომასალა"), + ("All Footage", "მთელი ვიდეომასალა"), + ("Ask User", "მომხმარებლისთვის კითხვა"), + ("Guess", "ვარაუდი"), + ("Ignore", "უგულებელყოფა"), + ("Straight", "პირდაპირი"), + ("Premultiplied", "წინასწარ გამრავლებული"), + ("Default Drag Import As", "გადათრევით იმპორტი ნაგულისხმევად როგორც"), + ("Footage", "ვიდეომასალა"), + ("Composition - Retain Layer Sizes", "კომპოზიცია - შრეების ზომების შენარჩუნებით"), + ("Export", "ექსპორტი"), + ("Default Output Folder", "ნაგულისხმევი გამოტანის საქაღალდე"), + ("Segment Sequences", "სეკვენციების სეგმენტებად დაყოფა"), + ("Files per Segment", "ფაილი სეგმენტში"), + ("files", "ფაილი"), + ("Segment Movie Files", "ვიდეოფაილების სეგმენტებად დაყოფა"), + ("Segment Size", "სეგმენტის ზომა"), + ("Append Bit Depth to File Name", "ბიტური სიღრმის ფაილის სახელზე დამატება"), + ("Audio", "აუდიო"), + ("Audio Hardware", "აუდიოაპარატურა"), + ("Preview Sample Rate", "წინასწარი ნახვის დისკრეტიზაციის სიხშირე"), + ("Audio Output Mapping", "აუდიოს გამოტანის ასახვა"), + ("Left", "მარცხენა"), + ("Right", "მარჯვენა"), + ("channel", "არხი"), + ("Disk", "დისკი"), + ("Disk Cache", "დისკის ქეში"), + ("Enable Disk Cache", "დისკის ქეშის ჩართვა"), + ("Maximum Disk Cache Size", "დისკის ქეშის მაქსიმალური ზომა"), + ("Disk Cache Folder", "დისკის ქეშის საქაღალდე"), + ("Media Cache", "მედიაქეში"), + ("Database and Cache Folder", "მონაცემთა ბაზისა და ქეშის საქაღალდე"), + ("Conformed Audio Folder", "მისადაგებული აუდიოს საქაღალდე"), + ("Memory & CPU", "მეხსიერება და CPU"), + ("RAM Reserved for Other Applications", "სხვა აპლიკაციებისთვის დაჯავშნული RAM"), + ("Cache Budgets", "ქეშის ლიმიტები"), + ("Layer Cache", "შრეების ქეში"), + ("Footage Frame Cache", "ვიდეომასალის კადრების ქეში"), + ("Preview (RAM) Cache", "წინასწარი ნახვის (RAM) ქეში"), + ("Reduce Cache Size When System Is Low on Memory", "ქეშის ზომის შემცირება, როცა სისტემას მეხსიერება აკლია"), + ("Video", "ვიდეო"), + ("Enable Video Preview Output", "ვიდეოს წინასწარი ნახვის გამოტანის ჩართვა"), + ("Video Device", "ვიდეომოწყობილობა"), + ("Floating Window", "მცურავი ფანჯარა"), + ("Full Screen", "მთელ ეკრანზე"), + ("Video Output During Playback", "ვიდეოს გამოტანა დაკვრისას"), + ("Mirror on Computer Monitor", "დუბლირება კომპიუტერის მონიტორზე"), + ("Disable Video Output When in Background", "ვიდეოს გამოტანის გამორთვა ფონურ რეჟიმში"), + ("Default 3D Renderer", "ნაგულისხმევი 3D რენდერერი"), + ("Show 3D Reference Axes", "3D საორიენტაციო ღერძების ჩვენება"), + ("Extended Viewer", "გაფართოებული დამთვალიერებელი"), + ("Realtime Shadows in Draft", "რეალურ დროში ჩრდილები მონახაზში"), + ("Scripting & Expressions", "სკრიპტები და ექსპრესიები"), + ("Application Scripting", "აპლიკაციის სკრიპტები"), + ("Allow Scripts to Write Files and Access Network", "სკრიპტებისთვის ფაილების ჩაწერისა და ქსელთან წვდომის დაშვება"), + ("Warn User When Executing Files", "მომხმარებლის გაფრთხილება ფაილების გაშვებისას"), + ("Enable JavaScript Debugger", "JavaScript-ის გამმართველის ჩართვა"), + ("Expressions Editor", "ექსპრესიების რედაქტორი"), + ("Font Size", "შრიფტის ზომა"), + ("Syntax Highlighting", "სინტაქსის გამოკვეთა"), + ("Line Numbers", "სტრიქონების ნომრები"), + ("Auto-complete", "ავტოდასრულება"), + ("Bracket Matching", "ფრჩხილების დაწყვილება"), + ("Word Wrap", "სიტყვების გადატანა"), + ("Show Expression Error Banner", "ექსპრესიის შეცდომის ზოლის ჩვენება"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "ექსპრესიები JavaScript-ის ძრავას იყენებს. სკრიპტის შეცდომები ფაილსა და სტრიქონს სკრიპტის კონსოლში აჩვენებს; ნაბიჯ-ნაბიჯ გამმართველი არ არის.", + ), + ("Roto Brush", "როტო-ფუნჯი"), + ("Segmentation Model", "სეგმენტაციის მოდელი"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "როტო-ფუნჯი 2.0 და 3.0 აქ არჩეულ მოდელს იყენებს (ვერსია 1.0 ყოველთვის კლასიკურ ძრავას იყენებს). მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Face Tracking", "სახის ტრეკინგი"), + ("Face Model", "სახის მოდელი"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ტრეკერის პანელში სახის ტრეკინგი (მხოლოდ კონტური და დეტალური ნიშნები) აქ არჩეულ მოდელს იყენებს. მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Match System", "სისტემის მიხედვით"), + ("Default", "ნაგულისხმევი"), + ("Stored, but not used by EffectCraft yet", "შენახულია, მაგრამ EffectCraft ჯერ არ იყენებს"), + ("Total Offset", "ჯამური წანაცვლება"), + ("Per Layer", "შრიდან შრემდე"), + ("Track", "ტრეკინგი"), +]; + +/// Georgian rows for interface text no other catalog has yet: strings the panels already send +/// through [`tr`] (form fields and titles, drop messages, screen-reader names) and the labels of +/// the Timeline's context menus. Kept apart from [`GEORGIAN`] so that table can keep listing +/// exactly the Chinese catalogs' sources; the tests hold these to the same rules. +const GEORGIAN_PANELS: &[(&str, &str)] = &[ + ("Search project", "პროექტში ძიება"), + ("Select an item to see its details", "მონიშნეთ ელემენტი მისი დეტალების სანახავად"), + ("Media Duration", "მედიის ხანგრძლივობა"), + ("Frame Rate", "კადრების სიხშირე"), + ("File Path", "ფაილის მისამართი"), + ("Comment", "კომენტარი"), + ("A/V Features", "A/V ფუნქციები"), + ("Keys", "საკვანძო კადრები"), + ("Layer Name", "შრის სახელი"), + ("Source Name", "წყაროს სახელი"), + ("Switches", "გადამრთველები"), + ("Modes", "რეჟიმები"), + ("Track Matte", "ტრეკ-მატი"), + ("Parent & Link", "მშობელი და ბმა"), + ("In", "შესვლა"), + ("Out", "გამოსვლა"), + ("Duration", "ხანგრძლივობა"), + ("Original color: click to go back to it", "საწყისი ფერი: დაწკაპეთ მასზე დასაბრუნებლად"), + ("Resize floating panel", "მცურავი პანელის ზომის შეცვლა"), + ("Render every n-th pixel", "ყოველი n-ური პიქსელის რენდერი"), + ("Color (#rrggbb)", "ფერი (#rrggbb)"), + ("Workspace", "სამუშაო სივრცე"), + ("Rename to", "ახალი სახელი"), + ("Open", "გახსნა"), + ("Custom Resolution", "მორგებული გარჩევადობა"), + ("Panel Background Color", "პანელის ფონის ფერი"), + ("New Workspace", "ახალი სამუშაო სივრცე"), + ("Edit Workspaces", "სამუშაო სივრცეების რედაქტირება"), + ("A script wants to open a file", "სკრიპტს ფაილის გახსნა სურს"), + ("Drop file to import", "ჩააგდეთ ფაილი იმპორტისთვის"), + ("Drop {} files to import", "ჩააგდეთ ფაილები იმპორტისთვის: {}"), + ( + "Skipped {} dropped file(s) without a local path. Use File ▸ Import ▸ File…", + "გამოტოვებულია ჩაგდებული ფაილი ლოკალური მისამართის გარეშე: {}. გამოიყენეთ ფაილი ▸ იმპორტი ▸ ფაილი…", + ), + ("Only one project can be opened per drop; skipped {} more", "ერთ ჩაგდებაზე მხოლოდ ერთი პროექტი იხსნება; გამოტოვებულია კიდევ: {}"), + ("Cannot open dropped project {}: {}", "ჩაგდებული პროექტის გახსნა შეუძლებელია, {}: {}"), + ("Cannot import dropped files: {}", "ჩაგდებული ფაილების იმპორტი შეუძლებელია: {}"), + ("A built-in preset has this name", "ამ სახელით ჩაშენებული პრესეტი არსებობს"), + ("Replaces your preset with this name", "ჩაანაცვლებს თქვენს პრესეტს ამ სახელით"), + ("Save Preset…", "პრესეტის შენახვა…"), + ("Delete Preset", "პრესეტის წაშლა"), + ("Null Settings", "ნულოვანი ობიექტის პარამეტრები"), + ("Click to sample text {}", "დაწკაპეთ ტექსტის ნიმუშის ასაღებად {}"), + ("Force alphabetical order", "ანბანური რიგის იძულება"), + ("Frame rate (fps)", "კადრების სიხშირე (fps)"), + ( + "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage.", + "კადრების სიხშირე, ალფა და საწყისი კადრი მოგვიანებით შეცვალეთ: ფაილი ▸ ვიდეომასალის ინტერპრეტაცია.", + ), + ("Import Kind", "იმპორტის სახე"), + ("Page", "გვერდი"), + ("Delay (s)", "დაყოვნება (წმ)"), + ("Amount (%)", "რაოდენობა (%)"), + ("Cascade (farther pins trail longer)", "კასკადი (შორეული სამაგრები უფრო დიდხანს ჩამორჩება)"), + ("Speed (%)", "სიჩქარე (%)"), + ("Smoothing", "დაგლუვება"), + ("Use Draft Deformation", "მონახაზის დეფორმაციის გამოყენება"), + ("Show Mesh", "ბადის ჩვენება"), + ("Scale X (%)", "მასშტაბი X (%)"), + ("Scale Y (%)", "მასშტაბი Y (%)"), + ("Rotation (degrees)", "შემობრუნება (გრადუსი)"), + ("Move X", "გადაადგილება X"), + ("Move Y", "გადაადგილება Y"), + ("Top", "ზემოდან"), + ("Width", "სიგანე"), + ("Height", "სიმაღლე"), + ("Shape", "ფიგურა"), + ("Mode", "რეჟიმი"), + ("Frame", "კადრი"), + ("Orientation", "ორიენტაცია"), + ("Position (pixels)", "პოზიცია (პიქსელი)"), + ("Position (percent of the composition)", "პოზიცია (კომპოზიციის პროცენტი)"), + ("Create", "შექმნა"), + ("Use Dimensions From", "ზომების აღება"), + ("Still Duration (seconds)", "უძრავი კადრის ხანგრძლივობა (წამი)"), + ("Add to Render Queue", "რენდერის რიგში დამატება"), + ("Sequence Layers", "შრეების თანმიმდევრობით განლაგება"), + ("Overlap", "გადაფარვა"), + ("Duration (seconds)", "ხანგრძლივობა (წამი)"), + ("Transition", "გადასვლა"), + ("Matte color (premultiplied)", "მატის ფერი (წინასწარ გამრავლებული)"), + ("Invert Alpha", "ალფას ინვერსია"), + ("Assume this frame rate", "ამ კადრების სიხშირის გამოყენება"), + ("Separate Fields", "ნახევარკადრების გაყოფა"), + ("Pixel Aspect Ratio", "პიქსელის გვერდების თანაფარდობა"), + ("Loop (times)", "ციკლი (ჯერ)"), + ("Assign Profile", "პროფილის მინიჭება"), + ("Preserve RGB", "RGB-ის შენარჩუნება"), + ("Interpret As Linear Light", "წრფივ სინათლედ ინტერპრეტაცია"), + ("Start Timecode", "საწყისი თაიმკოდი"), + ("Override Start", "დასაწყისის ხელით მითითება"), + ("Depth", "სიღრმე"), + ("Time display style", "დროის ჩვენების სტილი"), + ("Color engine", "ფერთა ძრავა"), + ("Working space", "სამუშაო სივრცე"), + ("Working gamma", "სამუშაო გამა"), + ("Linearize working space", "სამუშაო სივრცის წრფივად გარდაქმნა"), + ("Blend colors using 1.0 gamma", "ფერების შერევა 1.0 გამით"), + ("HDR on SDR displays and outputs", "HDR SDR ეკრანებსა და გამოტანებზე"), + ("Output color space", "გამოტანის ფერთა სივრცე"), + ("Video rendering and effects", "ვიდეოს რენდერი და ეფექტები"), + ("Time Span", "დროის მონაკვეთი"), + ("Invert", "ინვერსია"), + ("Blur (pixels before auto-trace)", "გაბუნდოვნება (პიქსელი, ვექტორიზაციამდე)"), + ("Tolerance (pixels)", "დაშვება (პიქსელი)"), + ("Threshold (%)", "ზღვარი (%)"), + ("Minimum Area (pixels)", "მინიმალური ფართობი (პიქსელი)"), + ("Corner Roundness (%)", "კუთხეების მომრგვალება (%)"), + ("Apply To New Layer", "ახალ შრეზე გამოყენება"), + ("Action", "მოქმედება"), + ("Sensitivity threshold (0–1, lower finds more)", "მგრძნობელობის ზღვარი (0–1, ნაკლები მეტს პოულობს)"), + ("Data", "მონაცემები"), + ("Time field (empty: automatic)", "დროის ველი (ცარიელი: ავტომატური)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ვიდეოს დასაწყისი (ISO თარიღი-დრო, hh:mm:ss, თაიმკოდი; ცარიელი: ფაილის თარიღი)"), + ("First sample at (comp seconds)", "პირველი ნიმუში (კომპოზიციის წამი)"), + ( + "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written.", + "იწერს Final Cut Pro XML-ს (.xml), რომელსაც Premiere Pro ხსნის ბრძანებით File ▸ Import. მშობლიური .prproj ფაილები არ იწერება.", + ), + ("File", "ფაილი"), + ("Format", "ფორმატი"), + ("Pre-render (ProRes 4444)", "წინასწარი რენდერი (ProRes 4444)"), + ("Precomps", "ჩადგმული კომპოზიციები"), + ("Frame rate", "კადრების სიხშირე"), + ("Description", "აღწერა"), + ("Category", "კატეგორია"), + ("Embed footage files", "ვიდეომასალის ფაილების ჩაშენება"), + ("Embed up to (MB)", "ჩაშენება მაქსიმუმ (MB)"), + ("Use the sRGB curve (ignore Gamma)", "sRGB მრუდის გამოყენება (გამის უგულებელყოფა)"), + ("ICC profile (replaces the numbers)", "ICC პროფილი (ცვლის რიცხვებს)"), + ("Rectangle", "მართკუთხედი"), + ("Ellipse", "ელიფსი"), + ("Orient Along Path", "ორიენტაცია ტრაექტორიის გასწვრივ"), + ("Orient Towards Camera", "ორიენტაცია კამერისკენ"), + ("Orient Towards Point of Interest", "ორიენტაცია ინტერესის წერტილისკენ"), + ("Single Composition", "ერთი კომპოზიცია"), + ("Multiple Compositions", "რამდენიმე კომპოზიცია"), + ("Dissolve Front Layer", "წინა შრის გადადნობა"), + ("Cross Dissolve Front and Back Layers", "წინა და უკანა შრეების ჯვარედინი გადადნობა"), + ("Interpret Straight - Unmatted", "პირდაპირად ინტერპრეტაცია - მატის გარეშე"), + ("Interpret Premultiplied - Matted With Color", "წინასწარ გამრავლებულად ინტერპრეტაცია - ფერით მატირებული"), + ("Upper Field First", "ჯერ ზედა ნახევარკადრი"), + ("Lower Field First", "ჯერ ქვედა ნახევარკადრი"), + ("Embedded / sRGB", "ჩაშენებული / sRGB"), + ("Use Source File Timecode", "წყარო ფაილის თაიმკოდის გამოყენება"), + ("Timecode", "თაიმკოდი"), + ("Frames", "კადრები"), + ("Feet + Frames (35mm)", "ფუტი + კადრი (35 მმ)"), + ("Feet + Frames (16mm)", "ფუტი + კადრი (16 მმ)"), + ("Clip", "მოჭრა"), + ("Compand", "კომპანდირება"), + ("Current Frame", "მიმდინარე კადრი"), + ("Work Area", "სამუშაო არე"), + ("Luminance", "სიკაშკაშე"), + ("Create Markers", "მარკერების შექმნა"), + ("Split Layers", "შრეების დაყოფა"), + ("Split and Precompose", "დაყოფა და კომპოზიციაში მოთავსება"), + ("CMX 3600 EDL (.edl, V1 only)", "CMX 3600 EDL (.edl, მხოლოდ V1)"), + ("OMF (.omf, audio)", "OMF (.omf, აუდიო)"), + ("Layers Premiere can't show (text, shapes, effects…)", "შრეები, რომლებსაც Premiere ვერ აჩვენებს (ტექსტი, ფიგურები, ეფექტები…)"), + ("All layers", "ყველა შრე"), + ("None (leave them out)", "არცერთი (გამოტოვება)"), + ("Nested sequences", "ჩადგმული სეკვენციები"), + ("Pre-render", "წინასწარი რენდერი"), + ("Puppet Follow-Through", "მარიონეტის ინერციული მოძრაობა"), + ("Puppet Record Options", "მარიონეტის ჩაწერის პარამეტრები"), + ("Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("Mask Shape", "ნიღბის ფორმა"), + ("Auto-Orientation", "ავტოორიენტაცია"), + ("Go to Time", "დროზე გადასვლა"), + ("Add Guide", "დამხმარე ხაზის დამატება"), + ("Add Percentage Guide", "პროცენტული დამხმარე ხაზის დამატება"), + ("New Composition from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Project Settings", "პროექტის პარამეტრები"), + ("Auto-trace", "ავტომატური ვექტორიზაცია"), + ("Scene Edit Detection", "სცენების ჭრილების ამოცნობა"), + ("Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("Export Adobe Premiere Pro Project", "Adobe Premiere Pro-ს პროექტის ექსპორტი"), + ("New Placeholder", "ახალი ჩანაცვლების ობიექტი"), + ("Solid Settings", "სოლიდის პარამეტრები"), + ("Save as Template", "შაბლონად შენახვა"), + ("My Custom RGB", "ჩემი მორგებული RGB"), + ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"), + ("Sort by {}, {}", "დალაგება: {}, {}"), + ("Sort by {}", "დალაგება: {}"), + ("Label of {}: {}", "{}-ის იარლიყი: {}"), + ("Comment of {}: {}", "{}-ის კომენტარი: {}"), + ("Project color depth: {}", "პროექტის ფერის სიღრმე: {}"), + ("Start (s)", "დასაწყისი (წმ)"), + ("Duration (s)", "ხანგრძლივობა (წმ)"), + ("Frames per second", "კადრი წამში"), + ("Bottom", "ქვემოდან"), + ("Bitrate (kbps)", "ბიტრეიტი (kbps)"), + ("Frames between key frames (0 = every 2 s)", "კადრები საკვანძო კადრებს შორის (0 = ყოველ 2 წმ-ში)"), + ("Click in the Composition panel to sample text {}", "დაწკაპეთ კომპოზიციის პანელში ტექსტის ნიმუშის ასაღებად {}"), + ("Video preview off: audio only", "ვიდეოს წინასწარი ნახვა გამორთულია: მხოლოდ აუდიო"), + ("Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("Effects", "ეფექტები"), + ("Video preview is off (Preview panel): audio only", "ვიდეოს წინასწარი ნახვა გამორთულია (წინასწარი ნახვის პანელი): მხოლოდ აუდიო"), + ("Solid…", "სოლიდი…"), + ("Freeze on Last Frame", "ბოლო კადრზე გაყინვა"), + ("Edit Value…", "მნიშვნელობის რედაქტირება…"), + ("Remove Expression", "ექსპრესიის მოცილება"), +]; + #[cfg(test)] mod tests { use super::*; @@ -4212,6 +5168,7 @@ mod tests { ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), @@ -4303,6 +5260,8 @@ mod tests { .chain(PORTUGUESE.iter().copied()) .chain(CZECH.iter().copied()) .chain(SPANISH.iter().copied()) + .chain(GEORGIAN.iter().copied()) + .chain(GEORGIAN_PANELS.iter().copied()) .collect() } @@ -4332,6 +5291,15 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); + let ka: Vec<&str> = GEORGIAN.iter().map(|(en, _)| *en).collect(); + assert_eq!(hans, ka, "the Georgian catalog lists different source strings from the Chinese ones"); + let ka_panels: Vec<&str> = GEORGIAN_PANELS.iter().map(|(en, _)| *en).collect(); + let shared: Vec<&str> = ka_panels.iter().copied().filter(|en| hans.contains(en)).collect(); + assert!(shared.is_empty(), "Georgian-only rows the Chinese catalogs have too (move them to GEORGIAN): {shared:?}"); + let mut own = ka_panels.clone(); + own.sort_unstable(); + own.dedup(); + assert_eq!(own.len(), ka_panels.len(), "a Georgian-only source string is listed twice"); // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH), ("Spanish", SPANISH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); @@ -4403,6 +5371,22 @@ mod tests { assert_eq!(tr("Cancel"), "Cancel"); } + /// Georgian reads its own rows; counts stay count-neutral and values keep their order. + #[test] + fn georgian_panels_translate_and_keep_project_content() { + set_current("ka"); + assert_eq!(tr("Composition Name:"), "კომპოზიციის სახელი:"); + assert_eq!(tr("Cancel"), "გაუქმება"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ნაბიჯი 1 / 3: Colour"); + assert_eq!(tr_args("{} minutes ago", &[&5]), "5 წუთის წინ"); + // A Georgian-only row (GEORGIAN_PANELS), which other languages still draw in English. + assert_eq!(tr("Solid Settings"), "სოლიდის პარამეტრები"); + set_current("cs"); + assert_eq!(tr("Solid Settings"), "Solid Settings"); + set_current("en"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 3e0984494..13986ac84 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -206,6 +206,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::georgian(app) { + "სერვისები" } else { "Services" } @@ -489,6 +491,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn georgian_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["სერვისები", "EffectCraft-ის დამალვა", "სხვების დამალვა", "ყველას ჩვენება"]); + } + #[test] fn hide_items_are_predefined_on_macos() { let spec = build(&app()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..e56275cc2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -120,7 +120,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { divider(&p, x0, w, y - 4.0, &t); section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation"); y += ROW_H + 10.0; - let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0)); + // Both buttons grow with their (translated) labels; English keeps its 116 / 84 px. + let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x; + let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0)); let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click()); p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text); @@ -142,7 +144,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Our own presets (original work, see presets/text_animators.json). - let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0)); + let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0)); let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click()); p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..2d44bb2fc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 624 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | @@ -300,8 +300,8 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie - Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–12 October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian, - Spanish, Brazilian Portuguese, Russian, French, German and Czech, and the panel catalog covers - Japanese, both Chinese, Ukrainian, Brazilian Portuguese and Czech. Next: effect and parameter + Spanish, Brazilian Portuguese, Russian, French, German, Czech and Georgian, and the panel catalog covers + Japanese, both Chinese, Ukrainian, Brazilian Portuguese, Czech and Georgian. Next: effect and parameter names, the remaining panel strings (Spanish, Russian, French, German), Korean and Italian, and native-speaker review of all of them. - Accessibility (keyboard navigation, screen readers, contrast), user documentation and diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..701850477 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599); Georgian: menus, panels and effect names) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -24,9 +24,9 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + counted at 470ea7a3; Georgian has every row the Chinese catalogs have. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese and Georgian (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes @@ -76,13 +76,14 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (3): +Other languages EffectCraft ships (4): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | | Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Georgian | `ka` | 1,510 (≈ 38%): menus 628 rows, panels 882 (the Chinese catalogs' 687 plus 195 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -104,6 +105,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 882 (195 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | diff --git a/docs/preferences.md b/docs/preferences.md index 59b0fc8a9..52fe92279 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br` / `ka`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`, a Georgian one (`ka`, `ka-GE`, `ka_GE.UTF-8`) selects `ka`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From e6c3e4178c8e0dbd6f8c4a654ff470af9f9f5031 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:08:09 +0300 Subject: [PATCH 0080/1386] model: OBJ/MTL references from another machine find the file next to the model, and a missing one is logged (#1274) --- Cargo.lock | 1 + crates/model/Cargo.toml | 1 + crates/model/src/obj.rs | 55 +++++++++++++++++++++++++++++++++------ crates/model/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce8fe1231..8ecad6d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1449,6 +1449,7 @@ version = "0.10.0" dependencies = [ "effectcraft-geom", "image", + "log", "miniz_oxide 0.8.9", "serde", "serde_json", diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 25397921a..9531e5745 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -14,5 +14,6 @@ effectcraft-geom = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +log = { workspace = true } image = { workspace = true } miniz_oxide = "0.8" diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs index 059278ecd..26c01b55a 100644 --- a/crates/model/src/obj.rs +++ b/crates/model/src/obj.rs @@ -14,18 +14,52 @@ use std::sync::Arc; use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr}; +/// The last component of a referenced path, treating `\` as a separator too: a path from a +/// machine of another kind (`C:\textures\checker.png`) still finds `checker.png` next to the +/// model (#1274). `None` when the name carries no path at all. +fn sibling_name(name: &str) -> Option<&str> { + let file = name.rsplit(['/', '\\']).next()?; + (!file.is_empty() && file.len() < name.len()).then_some(file) +} + +/// A material reference resolved against the model's folder: the name as written (options are +/// part of it, `-bm 1 file.png`), the whole value (a name containing spaces), then its file name +/// (an absolute path from another machine, #1274). +fn resolve_reference(resolve: &dyn Fn(&str) -> Option>, name: &str) -> Option> { + let mut tried: Vec<&str> = vec![]; + for candidate in [name.split_whitespace().last().unwrap_or(name), name, sibling_name(name).unwrap_or(name)] { + if tried.contains(&candidate) { + continue; + } + tried.push(candidate); + if let Some(b) = resolve(candidate) { + return Some(b); + } + } + None +} + /// Parse MTL text into named materials (textures appended to `textures`). pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option>, textures: &mut Vec>) -> Vec { let mut out: Vec = vec![]; let mut loaded: HashMap> = HashMap::new(); let mut tex = |name: &str, textures: &mut Vec>| -> Option { - // Options (`-bm 1 file.png`): the file name is the last token. - let file = name.split_whitespace().last()?.to_string(); - let t = *loaded.entry(file.clone()).or_insert_with(|| { - let b = resolve(&file)?; - let t = Texture::decode(&b).ok()?; - textures.push(Arc::new(t)); - Some(textures.len() - 1) + let key = name.trim().to_string(); + let t = *loaded.entry(key.clone()).or_insert_with(|| { + let Some(b) = resolve_reference(resolve, &key) else { + log::warn!("model: texture `{key}` was not found next to the model"); + return None; + }; + match Texture::decode(&b) { + Ok(t) => { + textures.push(Arc::new(t)); + Some(textures.len() - 1) + } + Err(e) => { + log::warn!("model: cannot decode texture `{key}`: {e}"); + None + } + } }); t.map(|texture| TexRef { texture, uv_set: 0 }) }; @@ -147,13 +181,18 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< } "mtllib" => { let name = line["mtllib".len()..].trim(); - for file in std::iter::once(name).chain(name.split_whitespace()) { + let mut found = false; + for file in std::iter::once(name).chain(name.split_whitespace()).chain(sibling_name(name)) { if let Some(b) = resolve(file) { let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures); materials.append(&mut ms); + found = true; break; } } + if !found && !name.is_empty() { + log::warn!("model: material library `{name}` was not found next to the model"); + } } "usemtl" => { let name = line["usemtl".len()..].trim(); diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 9206ef83f..c68db576c 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -119,6 +119,54 @@ fn obj_without_normals_gets_them() { assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err()); } +#[test] +fn obj_textures_from_another_machine_find_the_file_next_to_the_model() { + // No committed media: the material file and its textures are written by the test itself. + let d = std::env::temp_dir().join(format!("ec-obj-sibling-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let png = { + let img = image::RgbaImage::from_fn(2, 2, |x, y| { + if (x + y) % 2 == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) } + }); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + std::fs::write(d.join("checker.png"), &png).unwrap(); + std::fs::write(d.join("Diffuse Map.png"), &png).unwrap(); + let mtl = "newmtl Mat\nKd 0.640000 0.640000 0.640000\nmap_Kd C:\\Users\\somebody\\Desktop\\textures\\checker.png\n\ + newmtl Spaced\nKd 1.000000 1.000000 1.000000\nmap_Kd Diffuse Map.png\n\ + newmtl Missing\nKd 0.500000 0.500000 0.500000\nmap_Kd nowhere.png\n"; + let obj = "mtllib quad.mtl\nv -1 -1 0\nv 1 -1 0\nv 1 1 0\nv -1 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\ + usemtl Mat\nf 1/1 2/2 3/3\nf 1/1 3/3 4/4\n"; + // Like the media layer's resolver: a relative name joins the model's folder; an absolute + // path is only looked up where it points (here: nowhere else). + let resolve = |uri: &str| -> Option> { + let p = std::path::Path::new(uri); + if p.is_absolute() { return None; } + std::fs::read(d.join(p)).ok() + }; + let resolver = |uri: &str| -> Option> { + if uri == "quad.mtl" { Some(mtl.as_bytes().to_vec()) } else { resolve(uri) } + }; + let m = obj::parse(obj.as_bytes(), &resolver).unwrap(); + assert_eq!(m.materials.len(), 3); + let mat = &m.materials[0]; + let t = mat.base_color_tex.expect("the absolute map_Kd path finds checker.png next to the .obj"); + let tex = &m.textures[t.texture]; + assert_eq!((tex.width, tex.height), (2, 2)); + assert_eq!(tex.data[0], [1.0, 0.0, 0.0, 1.0]); + assert!((mat.base_color[0] - obj::srgb_to_linear(0.64)).abs() < 1e-6, "Kd still tints the texture"); + assert!(m.materials[1].base_color_tex.is_some(), "a texture name containing spaces is one name"); + assert!(m.materials[2].base_color_tex.is_none(), "a genuinely missing texture keeps the Kd colour"); + assert!((m.materials[2].base_color[0] - obj::srgb_to_linear(0.5)).abs() < 1e-6); + // The same fallback applies to the material library itself. + let here = obj.replace("mtllib quad.mtl", "mtllib C:\\build\\quad.mtl"); + assert_eq!(obj::parse(here.as_bytes(), &resolver).unwrap().materials.len(), 3); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn channel_interpolation_modes() { let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] }; From f2b0998390fcc1295a74b1501d95567cf0d8689f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 01:46:38 +0200 Subject: [PATCH 0081/1386] #1510: Behaviors animation presets (22 original, expression-driven) Problem: the built-in animation preset library had no Behaviors category (gap 7: 54 presets against After Effects' 621). Fix: 22 original Behaviors presets in crates/engine/presets/behaviors.json: Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse. Each puts a time-based expression on one transform property or opacity (no effects), built on the property's own value so it keeps running for the layer's whole duration. Listed under * Animation Presets > Behaviors. Sidecar, ATTRIBUTION.md, gaps.md row 7 and parity docs updated. Co-Authored-By: Claude Sonnet 5.5 --- ATTRIBUTION.md | 2 + crates/engine/presets/behaviors.json | 248 ++++++++++++++++++ .../engine/presets/behaviors.json.attribution | 7 + crates/engine/src/commands/anim_presets.rs | 9 +- crates/engine/src/tests_anim_presets.rs | 30 ++- crates/host/tests/background_presets.rs | 31 +++ docs/effects-parity.md | 4 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 9 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 crates/engine/presets/behaviors.json create mode 100644 crates/engine/presets/behaviors.json.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index be99cdce1..b0b5a12d0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -69,6 +69,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall `crates/engine/presets/transitions.json` (sidecar `.attribution`). - Backgrounds animation presets (23: drifting gradients, fog, cloud and smoke, plasma and magma, cells and crystals, scrolling grids and checks, pulse rings, bokeh, light sweep): `crates/engine/presets/backgrounds.json` (sidecar `.attribution`). +- Behaviors animation presets (22: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker): + `crates/engine/presets/behaviors.json` (sidecar `.attribution`). - Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` diff --git a/crates/engine/presets/behaviors.json b/crates/engine/presets/behaviors.json new file mode 100644 index 000000000..71e87db52 --- /dev/null +++ b/crates/engine/presets/behaviors.json @@ -0,0 +1,248 @@ +{ + "format": "effectcraft.animationPresets/1", + "notes": "Original EffectCraft behavior presets (MIT OR Apache-2.0), written from scratch. Each one drives a transform property or opacity with a time-based expression built on the property's own value, so the motion runs for the layer's whole duration and still follows the property's keyframes. Apply to any layer; no effects are added.", + "presets": [ + { + "id": "wiggleSoft", + "name": "Soft Wiggle", + "category": "Behaviors", + "description": "The layer wanders gently around its position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(1.2, 30); [w[0], w[1]]" + } + } + }, + { + "id": "shake", + "name": "Shake", + "category": "Behaviors", + "description": "A fast, tight shake of the layer's position.", + "layer": { + "expr": { + "transform/position": "var w = wiggle(14, 12); [w[0], w[1]]" + } + } + }, + { + "id": "floatUpDown", + "name": "Float", + "category": "Behaviors", + "description": "The layer bobs up and down like it floats on water.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(Math.sin(time*2)*18)]" + } + } + }, + { + "id": "sway", + "name": "Sway", + "category": "Behaviors", + "description": "The layer sways left and right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.6)*40), value[1]+(0)]" + } + } + }, + { + "id": "orbit", + "name": "Orbit", + "category": "Behaviors", + "description": "The layer travels round a circle around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.cos(time*2)*80), value[1]+(Math.sin(time*2)*80)]" + } + } + }, + { + "id": "figureEight", + "name": "Figure Eight", + "category": "Behaviors", + "description": "The layer traces a figure eight around its position.", + "layer": { + "expr": { + "transform/position": "[value[0]+(Math.sin(time*1.5)*100), value[1]+(Math.sin(time*3)*45)]" + } + } + }, + { + "id": "bouncingBall", + "name": "Bouncing Ball", + "category": "Behaviors", + "description": "The layer bounces up from its position again and again.", + "layer": { + "expr": { + "transform/position": "[value[0]+(0), value[1]+(-Math.abs(Math.sin(time*3.2))*140)]" + } + } + }, + { + "id": "driftRight", + "name": "Drift Right", + "category": "Behaviors", + "description": "The layer drifts steadily to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*40), value[1]+(0)]" + } + } + }, + { + "id": "driftDiagonal", + "name": "Drift Diagonal", + "category": "Behaviors", + "description": "The layer drifts slowly down and to the right.", + "layer": { + "expr": { + "transform/position": "[value[0]+(time*30), value[1]+(time*18)]" + } + } + }, + { + "id": "pendulum", + "name": "Pendulum", + "category": "Behaviors", + "description": "The layer swings like a pendulum.", + "layer": { + "expr": { + "transform/rotation": "value + Math.sin(time*2.4)*25" + } + } + }, + { + "id": "wobble", + "name": "Wobble", + "category": "Behaviors", + "description": "The layer rotates with an irregular wobble.", + "layer": { + "expr": { + "transform/rotation": "wiggle(2, 10)" + } + } + }, + { + "id": "spinSlow", + "name": "Spin Slow", + "category": "Behaviors", + "description": "The layer rotates once every ten seconds.", + "layer": { + "expr": { + "transform/rotation": "value + time*36" + } + } + }, + { + "id": "spinFast", + "name": "Spin Fast", + "category": "Behaviors", + "description": "The layer rotates twice a second.", + "layer": { + "expr": { + "transform/rotation": "value + time*720" + } + } + }, + { + "id": "pulse", + "name": "Pulse", + "category": "Behaviors", + "description": "The layer swells and shrinks smoothly.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.12*Math.sin(time*4)), value[1]*(1+0.12*Math.sin(time*4))]" + } + } + }, + { + "id": "heartbeat", + "name": "Heartbeat", + "category": "Behaviors", + "description": "Two quick beats, then a rest, repeating every second.", + "layer": { + "expr": { + "transform/scale": "var t = time % 1; var b = t < 0.3 ? Math.sin(t/0.3*Math.PI) : (t < 0.6 ? 0.7*Math.sin((t-0.3)/0.3*Math.PI) : 0); var k = 1 + 0.2*b; [value[0]*k, value[1]*k]" + } + } + }, + { + "id": "breathe", + "name": "Breathe", + "category": "Behaviors", + "description": "The layer slowly grows and settles back.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.06*Math.sin(time*1.1)), value[1]*(1+0.06*Math.sin(time*1.1))]" + } + } + }, + { + "id": "rubberBand", + "name": "Rubber Band", + "category": "Behaviors", + "description": "Width and height stretch against each other.", + "layer": { + "expr": { + "transform/scale": "[value[0]*(1+0.15*Math.sin(time*5)), value[1]*(1-0.15*Math.sin(time*5))]" + } + } + }, + { + "id": "jitterScale", + "name": "Jitter Scale", + "category": "Behaviors", + "description": "The layer's size jitters randomly.", + "layer": { + "expr": { + "transform/scale": "var w = wiggle(8, 6); [w[0], w[1]]" + } + } + }, + { + "id": "blink", + "name": "Blink", + "category": "Behaviors", + "description": "The layer switches on and off twice a second.", + "layer": { + "expr": { + "transform/opacity": "(Math.floor(time*4) % 2 === 0) ? value : 0" + } + } + }, + { + "id": "strobe", + "name": "Strobe", + "category": "Behaviors", + "description": "The layer flashes on for a few frames each beat.", + "layer": { + "expr": { + "transform/opacity": "((time*6) % 1 < 0.25) ? value : 0" + } + } + }, + { + "id": "flicker", + "name": "Flicker", + "category": "Behaviors", + "description": "The layer's opacity flickers like a failing lamp.", + "layer": { + "expr": { + "transform/opacity": "var w = wiggle(14, 45); Math.max(0, Math.min(100, w))" + } + } + }, + { + "id": "fadePulse", + "name": "Fade Pulse", + "category": "Behaviors", + "description": "Opacity breathes between full and a third.", + "layer": { + "expr": { + "transform/opacity": "value*(0.67+0.33*Math.sin(time*2.5))" + } + } + } + ] +} diff --git a/crates/engine/presets/behaviors.json.attribution b/crates/engine/presets/behaviors.json.attribution new file mode 100644 index 000000000..7c71c62e8 --- /dev/null +++ b/crates/engine/presets/behaviors.json.attribution @@ -0,0 +1,7 @@ +asset: behaviors.json +title: EffectCraft Behaviors animation presets (Soft Wiggle, Shake, Float, Sway, Orbit, Figure Eight, Bouncing Ball, Drift Right, Drift Diagonal, Pendulum, Wobble, Spin Slow, Spin Fast, Pulse, Heartbeat, Breathe, Rubber Band, Jitter Scale, Blink, Strobe, Flicker, Fade Pulse) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-15 by vangel76 (Claude Code agent, gap 7, #1510) +notes: Expressions and values written from scratch for EffectCraft on its own transform properties and expression engine; not derived from any Adobe preset (.ffx) or its values. diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/commands/anim_presets.rs index fc3f95143..88022a0e3 100644 --- a/crates/engine/src/commands/anim_presets.rs +++ b/crates/engine/src/commands/anim_presets.rs @@ -1,6 +1,7 @@ //! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the //! Transitions category (original presets built on our Transition effects and layer opacity) and -//! the Backgrounds category (generators, noise and colour effects driven by expressions). +//! the Backgrounds category (generators, noise and colour effects driven by expressions) and the +//! Behaviors category (transform and opacity driven by expressions, no effects). //! //! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting //! at the current time, on every selected layer. The library is data @@ -26,13 +27,17 @@ pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json" /// The original background presets (`presets/backgrounds.json`). pub const BACKGROUNDS_JSON: &str = include_str!("../../presets/backgrounds.json"); +/// The original behavior presets (`presets/behaviors.json`): expression-driven motion on a layer's +/// transform and opacity, no effects. +pub const BEHAVIORS_JSON: &str = include_str!("../../presets/behaviors.json"); + /// Keys per animated property, so a hostile inline preset can't allocate without bound. const MAX_KEYS: usize = 256; /// Longest expression text an inline preset may carry. const MAX_EXPR: usize = 4096; fn presets() -> Vec { - [TRANSITIONS_JSON, BACKGROUNDS_JSON] + [TRANSITIONS_JSON, BACKGROUNDS_JSON, BEHAVIORS_JSON] .iter() .flat_map(|src| serde_json::from_str::(src).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()) .collect() diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests_anim_presets.rs index b9f529c45..d23996e67 100644 --- a/crates/engine/src/tests_anim_presets.rs +++ b/crates/engine/src/tests_anim_presets.rs @@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() { #[test] fn preset_catalogue_is_unique_and_categorised() { let all = crate::animation_presets(); - let known = ["Transitions", "Backgrounds"]; + let known = ["Transitions", "Backgrounds", "Behaviors"]; let mut ids = std::collections::HashSet::new(); let mut names = std::collections::HashSet::new(); for (id, name, cat) in &all { @@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() { assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes"); } let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count(); - assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20); + assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20); } #[test] @@ -126,3 +126,29 @@ fn preset_expressions_and_point_fractions() { assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0])); assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2); } + +/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform +/// property, and undo removes it again. +#[test] +fn every_behavior_preset_applies_and_undoes() { + let (mut s, solid) = session(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + let driven = |s: &Session| { + let l = &s.active_comp().unwrap().layers[0]; + ["transform/position", "transform/scale", "transform/rotation", "transform/opacity"] + .iter() + .filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression())) + .count() + }; + for p in &list { + let id = p["id"].as_str().unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies"); + assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects"); + assert_eq!(driven(&s), 1, "{id}: exactly one property is driven"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(driven(&s), 0, "{id}: undo removes the expression"); + } +} diff --git a/crates/host/tests/background_presets.rs b/crates/host/tests/background_presets.rs index e3e44b009..c03ec0066 100644 --- a/crates/host/tests/background_presets.rs +++ b/crates/host/tests/background_presets.rs @@ -58,3 +58,34 @@ fn every_background_preset_renders_moves_and_undoes() { assert_eq!(fx, 0, "{id}: undo removes the effects"); } } + +/// #1510: each Behaviors preset keeps its layer moving (position, scale, rotation or opacity +/// changes over time) and renders finite, deterministic frames. +#[test] +fn every_behavior_preset_renders_and_moves() { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "BH", "width": 160, "height": 90, "frameRate": 30, "duration": 8})).unwrap(); + // A small white square in the middle so position, scale and rotation changes are visible. + let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap(); + let list = list.as_array().unwrap().clone(); + assert!(list.len() >= 20, "{}", list.len()); + for p in &list { + let id = p["id"].as_str().unwrap(); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); + assert_eq!(r["skipped"], json!([]), "{id}"); + let mut frames = Vec::new(); + // Times chosen off the periods of every preset so no two samples coincide. + for t in [0.03, 0.13, 0.41, 0.97, 1.73] { + let (px, _, _, nan) = frame(&s, t); + assert!(!nan, "{id} @ {t}: NaN or infinite pixel"); + let (again, ..) = frame(&s, t); + assert!(px == again, "{id} @ {t}: render is not deterministic"); + frames.push(px); + } + let moved = (1..frames.len()).any(|i| diff(&frames[0], &frames[i]) > 20.0); + assert!(moved, "{id}: the layer does not move ({:?})", (1..frames.len()).map(|i| diff(&frames[0], &frames[i])).collect::>()); + s.execute("edit.undo", json!({})).unwrap(); + } +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c5fbc5ba8..c2672f88f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | | **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | -| Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | +| Animation presets | **76** original presets (8 text-animator, 23 Transitions, 23 Backgrounds, 22 Behaviors) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`, `crates/engine/presets/behaviors.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | **Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a @@ -56,7 +56,7 @@ implemented under a generic name ("Key Light"). **≈ 70–130 h**: a fidelity corpus case per effect at default and one non-default setting, compared with After Effects renders, and the fixes they turn up (40–80 h, most of it parallel, but every reference render needs After Effects on the owner's Mac); an original preset library covering -After Effects' preset categories (Behaviors, Shapes, Synthetics, Text, Image effects remain; Backgrounds and +After Effects' preset categories (Shapes, Synthetics, Text, Image effects remain; Backgrounds, Behaviors and Transitions exist) 15–35 h; the open effect bugs 10–20 h. Moving Fractal and the analysis effects to the GPU is performance, not parity. diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..ba62790d5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -63,7 +63,7 @@ are grouped by area; the issue tracker has the rest. | 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | -| 7 | **Animation presets: 54 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 54 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops) and read only our `.ecpreset`. Missing: Behaviors, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #932 Convert to Bezier Path, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553 | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd8..53f8f26ab 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -120,7 +120,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | -| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | +| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | | Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); late audio (#504, #335); #329 and #554 are closed; no benchmark against After Effects | From 4133f47e2f0c3eb9f3820a6b1c6b4934aca99026 Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Thu, 15 Oct 2026 23:13:12 +0800 Subject: [PATCH 0082/1386] i18n: the panel tabs, and the labels the call-shape scan cannot see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two English surfaces the translation work so far could not reach. The dock's tab titles: `PanelKind::title()` is drawn by `dock_ui.rs`'s `tab_titles`, which listed only the tabs that name a comp, a layer or a script and let every other one fall back to `title()`'s English - so every panel tab (`Timeline`, `Preview`, `Properties`, `Effects & Presets`, ...) stayed English. The Window menu already carries a (`window.panel`, title) row in every catalog, so the dock reads that pair and falls back to the panel catalog for `Markers` (the one panel only it names); `History` and `ScriptUI Panel` have no row anywhere and keep their English names. `app.auto`'s labels and the dock's serialized state keep the English title, which the control channel needs. The scan's blind shapes: `converted_panels_have_no_unwrapped_literals` only sees a literal that follows a call token, so a label passed to a helper, the third argument of `Painter::text`, a `(key, label, ...)` table, a `let` binding and a `match` arm were all invisible. The two that can be checked without guessing are added (`helper_and_painter_labels_are_translated`), with the same single-character exemption the call scan makes, and the 25 sites they find now ask the catalog (`Apply`, `Analyze`, `Cancel`, `Create Camera`, `Add Point`, `Remove Point`, `Clear`, `Render`, `Stop`, `Save Current as Template...`, `New Composition From Footage`, the three search fields, `(no composition)`, `per second`, `Waveform`, `steps`), with 15 rows added to the Simplified and Traditional catalogs in the conventions those tables already use (Render 算圖, Apply 套用, Add Point 添加點). The `uic!` labels and the Keyboard Shortcuts dialog are a different shape and are still to decide (#1451). Verified on Linux: cargo fmt --all --check, cargo test -p effectcraft-ui-egui (every suite passes), cargo clippy -p effectcraft-ui-egui --all-targets -- -D warnings clean. Reverting the dock's catalog lookup fails `the_dock_draws_every_panel_it_can_in_the_interface_language`; unwrapping a helper or a painter label fails `helper_and_painter_labels_are_translated` ("panels/project.rs:434: the interface text \"Search project\" that widgets::search_field draws is not in the catalog"). --- crates/ui-egui/src/dock_ui.rs | 19 ++- crates/ui-egui/src/i18n.rs | 29 +++++ crates/ui-egui/src/i18n/ui.rs | 113 ++++++++++++++++++ crates/ui-egui/src/panels/anim_tools.rs | 8 +- .../ui-egui/src/panels/camera_tracker_ui.rs | 6 +- crates/ui-egui/src/panels/effect_controls.rs | 4 +- crates/ui-egui/src/panels/effects_presets.rs | 2 +- crates/ui-egui/src/panels/flowchart.rs | 2 +- crates/ui-egui/src/panels/fx_editors.rs | 6 +- crates/ui-egui/src/panels/learn.rs | 2 +- crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/mod.rs | 2 +- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 4 +- crates/ui-egui/src/panels/templates.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 6 +- crates/ui-egui/src/panels/viewer.rs | 12 +- 17 files changed, 192 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..7e7a3f787 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -39,11 +39,24 @@ impl EffectcraftApp { out.push((panels::viewers::panel(id), t)); } } - let Some(comp) = self.session.active_comp() else { return out }; - if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { + if let Some(comp) = self.session.active_comp() + && let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) + { out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); out.push((PanelKind::Properties, format!("Properties: {}", l.name))); } + // The panels named for themselves, in the interface language. The Window menu has carried a + // (`window.panel`, title) row for these in every catalog that exists, so the dock reads the + // same pair instead of drawing `title()`'s English; a panel the menu does not list (`History`, + // `ScriptUI Panel`) keeps its English name, and a tab that names a comp or a layer is listed + // above and wins, being found first. + for p in PanelKind::ALL { + // The Window menu lists most of these; `Markers` is only in the panel catalog, and the + // two panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::label(self, "window.panel", p.title()); + let label = if listed == p.title() { crate::i18n::tr(p.title()) } else { listed }; + out.push((p, label.to_string())); + } out } @@ -570,7 +583,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::label(self, "window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dde..061a62601 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -6901,6 +6901,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::label(self, "window.panel", p.title())"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn zh_hans_translates_labels_computed_titles_and_keeps_names_verbatim() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..186d272e2 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1366,6 +1366,21 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2093,6 +2108,21 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4464,6 +4494,89 @@ mod tests { } } + /// Labels drawn through a helper's *argument* or through `Painter::text` are invisible to the + /// scan above, which only looks at a literal right after the call token: a `widgets::` helper + /// takes its label as its second or third argument, and `Painter::text` takes the string third. + /// Both shapes are checked here, so an English literal there is caught like any other. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. + fn args_of(s: &str) -> Vec<&str> { + let (mut out, mut depth, mut start, mut instr) = (Vec::new(), 0i32, None, false); + for (i, c) in s.char_indices() { + match c { + '"' if !instr => { + instr = true; + if start.is_none() { + start = Some(i); + } + } + '"' => instr = false, + '(' | '[' | '{' if !instr => depth += 1, + ')' | ']' | '}' if !instr => { + if depth == 0 { + break; + } + depth -= 1; + } + ',' if !instr && depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !instr && !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + } + } + if let Some(st) = start { + out.push(s[st..].trim()); + } + out + } + + for (path, source) in CONVERTED { + // The test modules of these files draw nothing; they hold sample strings. + let source = source.split("#[cfg(test)]").next().unwrap_or(source); + for (n, line) in source.lines().enumerate() { + let code = line.split("//").next().unwrap_or(line); + let check = |args: Vec<&str>, index: usize, what: &str| { + let Some(arg) = args.get(index) else { return }; + // A single character is a symbol or a glyph, not interface text - the same + // exemption the call-shape scan above makes. + let inner = arg.strip_prefix('"').and_then(|a| a.strip_suffix('"')).filter(|i| !i.contains('"')); + match inner { + Some(text) if text.chars().count() >= 2 && !VERBATIM.contains(&text) => { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", n + 1); + } + _ => {} + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + let mut from = 0; + while let Some(at) = code[from..].find(&marker) { + let at = from + at + marker.len(); + check(args_of(&code[at..]), *index, &format!("widgets::{call}")); + from = at; + } + } + let mut from = 0; + while let Some(at) = code[from..].find(".text(") { + from = at + 6; + check(args_of(&code[from..]), 2, "Painter::text"); + } + } + } + } + /// The Settings pages come from the engine as data, so the literal scan above cannot see them: /// every title, section, label, choice, button, note and unit they draw needs a Chinese row /// (#1178). Language names are written in their own language, and a few labels read the same diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 15a4f4f89..3f7c0ca46 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -109,7 +109,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -150,7 +150,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -321,7 +321,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -392,7 +392,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::new("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 2bccfae07..1fe187672 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,7 +426,7 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..24c096977 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -756,12 +756,12 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::new(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..e2544442c 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -276,7 +276,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); // Contents menu. diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index f59d35982..f36fa86f5 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 1ae74caa2..21d56deba 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -786,19 +786,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::new(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index b613791fd..1e52b5c25 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..c1b7d9ee5 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..21f009912 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -240,7 +240,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..11fc5c15b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -431,7 +431,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); let mut q = app.ui.project_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search project", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t); app.ui.project_search = q; app.auto.add("project.search", sr, "Search"); // Column header: Name, Label, then the visible optional columns. diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..6f9a539d0 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -456,7 +456,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -469,7 +469,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::new("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ec65ee6d7..a0db75d92 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -69,7 +69,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::new("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::new("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..7661ca3cb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1386,7 +1386,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let Some(cid) = app.session.active_comp_id() else { - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -1517,7 +1517,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -2501,7 +2501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { RowKind::Waveform { item } => { lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..9c77d62a8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -745,7 +745,13 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo // Close the locked viewer. let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0)); let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click()); - p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }); + p.text( + close.center(), + Align2::CENTER_CENTER, + crate::i18n::tr("\u{00d7}"), + Tokens::ui(14.0), + if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }, + ); app.auto.add("viewer.locked.close", close, "Close Locked Viewer"); if resp.clicked() { let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({})); @@ -2504,12 +2510,12 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let c = rect.center(); p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::new("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::new("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::new("empty-fromfootage")).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::new("empty-fromfootage")).clicked() { let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); From 93b06a6eab9c1636524be8173d95b074f02b20b8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 22:56:38 +0200 Subject: [PATCH 0083/1386] Bump FilmCraft to 4e2d0bc2 (part of #804) Problem: the hardware-decoding work (#819) needs a newer FilmCraft than the pinned 2c5ba7a, and the pin bump should be reviewable apart from it. Fix: move all filmcraft-* deps to 4e2d0bc2. Adapt to MediaInfo.audio -> audio_streams and VideoStreamInfo.hdr; ask the pool for a quarter into the frame because FilmCraft now rounds requests half up; adapt rotation and the AAC downmix; use Decoder::with_threads(1) in the VP9 tests. No filmcraft-platform, no hardware setting. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 115 ++++++++++++++------------ Cargo.toml | 32 +++---- crates/export/tests/vp9_roundtrip.rs | 6 +- crates/host/tests/timeline_interop.rs | 3 +- crates/interchange/src/export.rs | 6 +- crates/interchange/src/import.rs | 4 +- crates/interchange/src/tests.rs | 6 +- crates/media/src/downmix.rs | 39 ++++++++- crates/media/src/pool.rs | 11 ++- crates/media/src/probe.rs | 15 +++- 10 files changed, 151 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce8fe1231..98e00791d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,8 +2025,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2034,16 +2034,26 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-apv" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-av1" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2052,21 +2062,22 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-cfb" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-codecs" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-aac", "filmcraft-ac3", + "filmcraft-apv", "filmcraft-av1", "filmcraft-bitstream", "filmcraft-color", @@ -2094,16 +2105,16 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-dnx" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2112,8 +2123,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2123,16 +2134,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2152,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2151,8 +2162,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2161,8 +2172,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2178,8 +2189,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2187,13 +2198,13 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-media" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2206,8 +2217,8 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2215,31 +2226,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-ogg" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-opus" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-project" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2252,8 +2263,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2262,16 +2273,16 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-vp9" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17f..cb8b7ea38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,26 +71,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/vp9_roundtrip.rs index e7c2b0346..1432e3892 100644 --- a/crates/export/tests/vp9_roundtrip.rs +++ b/crates/export/tests/vp9_roundtrip.rs @@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec, Vec, Vec) { } fn decode(frame: &[u8]) -> filmcraft_vp9::Picture { - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let mut pics = d.decode(frame, 0).expect("decode"); assert_eq!(pics.len(), 1); pics.remove(0) @@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { let (w, h) = (cfg.width as usize, cfg.height as usize); let mut e = Vp9Encoder::new(cfg); - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let (mut bytes, mut db) = (0usize, 0.0); for t in 0..n { let (y, u, v) = moving(w, h, t, kind); @@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() { } let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") }; // The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg. - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); for (t, (f, r)) in frames.iter().zip(&recon).enumerate() { let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}")); assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}"); diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/timeline_interop.rs index 5123dc721..9688a409f 100644 --- a/crates/host/tests/timeline_interop.rs +++ b/crates/host/tests/timeline_interop.rs @@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId { color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..da6ef1382 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -684,8 +684,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option { MediaKind::AudioOnly => FootageKind::Audio, MediaKind::Still => FootageKind::Still, MediaKind::ImageSequence => FootageKind::Sequence, - _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ if v.is_none() && info.has_audio() => FootageKind::Audio, _ => FootageKind::Video, }; let _ = name; @@ -284,7 +284,7 @@ impl Importer<'_> { frame_rate: rate_in(info.frame_rate()), duration: tick_in(info.duration), has_video: kind != FootageKind::Audio, - has_audio: info.audio.is_some() || kind == FootageKind::Audio, + has_audio: info.has_audio() || kind == FootageKind::Audio, alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, codec: v.map(|v| v.codec.clone()).unwrap_or_default(), missing: true, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..6e29c79d2 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }) + .into_iter() + .collect(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 2ef417dd7..e8a9237e0 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -64,11 +64,30 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } +/// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 +/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, +/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +pub fn decoded_aac_gains(count: usize) -> Vec { + match count { + // C L R Cs, reordered to L R C Cs: the same gains, permuted. + 4 => { + let g = stereo_gains(4, true); + [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() + } + 3 | 5 | 6 => stereo_gains(count, false), + _ => stereo_gains(count, true), + } +} + /// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a /// channel is too short for count as silence, extra frames in `out` stay as they are. pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = stereo_gains(channels.len(), aac); - for (ch, g) in channels.iter().zip(&gains) { + let gains = if aac { decoded_aac_gains(channels.len()) } else { stereo_gains(channels.len(), false) }; + mix(channels, &gains, out); +} + +fn mix(channels: &[Vec], gains: &[Pair], out: &mut [f32]) { + for (ch, g) in channels.iter().zip(gains) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -98,6 +117,22 @@ mod tests { } } + #[test] + fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { + // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. + for n in [3, 5, 6] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); + } + let g = decoded_aac_gains(4); + assert_eq!(g.len(), 4); + assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); + assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); + assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); + for n in [1, 2, 7, 8, 9] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); + } + } + #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 98c707108..33fb0b26f 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -509,7 +509,7 @@ impl MediaPool { let path: Arc = footage.path.as_str().into(); let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); - let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + let native = src.info().audio().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { return read_stereo(&src, s0, frames, rate); } @@ -700,9 +700,12 @@ impl Inner { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); let i = Self::frame_index(rate, t, n, footage.loop_count); - // Middle of the frame in the file's own timing (robust to timestamp rounding). + // A quarter into the frame in the file's own timing: FilmCraft rounds a request to + // the track's nearest timestamp unit (halves up), so the middle of a frame would + // land on the next one when the timescale is the frame rate; a quarter still + // tolerates timestamps rounded either way. let native = footage.native_rate.unwrap_or(rate); - let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); + let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 4); Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } @@ -915,7 +918,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); + let aac = src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")); crate::downmix::downmix_into(&buf.channels, aac, dst); Ok(out) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..7c5d1c491 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -119,7 +119,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - (_, None) => FootageKind::Audio, }; let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate)); - let codec = match (v, info.audio.as_ref()) { + let codec = match (v, info.audio()) { (Some(v), _) => v.codec.clone(), (None, Some(a)) => a.codec.clone(), _ => String::new(), @@ -134,7 +134,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - native_rate: None, duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) }, has_video: v.is_some(), - has_audio: info.audio.is_some(), + has_audio: info.has_audio(), alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, premul_color: [0.0; 3], loop_count: 1, @@ -239,12 +239,19 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other -/// containers have none. +/// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this +/// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; + // A pure rotation is already applied by FilmCraft's decoder (frames and size come out + // upright); only the headers it leaves as stored (mirrored, scaled, sheared) are turned here. + if track.display_rotation().is_some() { + return Orientation::NoTransforms; + } + let m = track.matrix; // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. let (a, b) = (m[0] as i64, m[1] as i64); match (a.abs() >= b.abs(), a >= 0, b >= 0) { From 74517a52da4e35cd6804b7ff1d2ebccd7d139390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Thu, 15 Oct 2026 15:37:14 +0200 Subject: [PATCH 0084/1386] Cryptomatte: the GPU path skips the ID colours unless the Colors display reads them, and the CPU path keys the layer in place instead of copying it (#473) Output is bit-identical (all displays, CPU and GPU). At 2560x1440 with 3 ranks and 2000 IDs on 8 cores the CPU effect drops from ~87-135 ms to ~44-54 ms and the GPU path's aux reduction from 38 to 8 ms for Matte, Preview and Matte Only. --- crates/effects/src/channel3d.rs | 28 +++++++++++++++++++++++----- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_depth.rs | 5 ++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7884e8084..32fda883d 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -413,10 +413,16 @@ fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] { /// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU /// effect uploads this image and samples it per layer pixel. pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image { + crypto_aux_image_with(a, layer, sel, true) +} + +/// [`crypto_aux_image`]; with `colors` false the RGB stays 0 and only the matte (A) is computed, +/// for the displays that never read the ID colours. +pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], colors: bool) -> Image { let planes = a.crypto_rank_planes(layer); let mut img = Image::new(a.width, a.height); img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let c = crypto_colors(&planes, i); + let c = if colors { crypto_colors(&planes, i) } else { [0.0; 3] }; *px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)]; }); img @@ -436,7 +442,6 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let display = ctx.params.e("display"); let matte_only = ctx.params.b("matteOnly"); let planes = a.crypto_rank_planes(&layer); - let src = b.img.clone(); b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k)); if matte_only { @@ -450,11 +455,11 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); [c[0], c[1], c[2], 1.0] } - // Matte: the layer keyed by the selection. - 1 => src.data[i].map(|v| v * m), + // Matte: the layer keyed by the selection (each pixel only reads itself, so no copy). + 1 => px.map(|v| v * m), // Preview: the layer with the selection tinted. _ => { - let s = src.data[i]; + let s = *px; [s[0] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[1] * (1.0 - 0.5 * m) + 0.5 * m * s[3], s[2] * (1.0 - 0.5 * m), s[3]] } }; @@ -835,6 +840,19 @@ mod tests { } } + /// Without the ID colours the matte (A) is bit-identical and the RGB is 0, on the edge values. + #[test] + fn crypto_aux_image_without_colors_keeps_the_matte() { + let a = edge_aux(true); + let sel = edge_selection(); + let full = crypto_aux_image(&a, "CryptoObject", &sel); + let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, false); + for (f, m) in full.data.iter().zip(&matte.data) { + assert_eq!(f[3].to_bits(), m[3].to_bits()); + assert_eq!(m[..3], [0.0; 3]); + } + } + /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) /// get an empty matte and no ID colour. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..4c1ea60b7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -110,7 +110,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 4931afe2a..a74ff717b 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -278,7 +278,10 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option let sel = effectcraft_effects::selection_hashes(pr.s("selection"), manifest); // Per aux pixel: coverage-weighted ID colours (RGB) and the selection's coverage (A), on all // cores with the rank planes resolved once. - let img = effectcraft_effects::crypto_aux_image(a, &layer, &sel); + // The ID colours are only read by the Colors display; Matte, Preview and Matte Only need the + // matte alone. + let colors = pr.e("display") == 0 && !pr.b("matteOnly"); + let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, colors); let tex = e.g.upload_image(&img)?; let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]); let mut p = op(7); From 34d5f00af86eada959d71c86bec7614a6654a979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Thu, 15 Oct 2026 15:48:46 +0200 Subject: [PATCH 0085/1386] Cryptomatte: Colors remembers each rank's last ID colour instead of hashing every pixel (#473) Bit-identical output. At 2560x1440, 3 ranks, 2000 IDs on 8 cores: CPU Colors ~69 -> ~49 ms, the GPU path's aux reduction ~35 -> ~14 ms. --- crates/effects/src/channel3d.rs | 52 ++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 32fda883d..26fbfe7a4 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -395,13 +395,37 @@ fn crypto_matte(planes: &RankPlanes, sel: &[u32], i: usize) -> f32 { .clamp(0.0, 1.0) } +/// [`id_color`] of the last ID seen on each rank plane, per worker. Neighbouring pixels mostly +/// carry the same IDs, so this skips the hash and the HSL conversion for most of them. Keyed by the +/// ID's bits, so it returns exactly what [`id_color`] would. +/// +/// Keep the per-pixel loop below as it is: with NaN coverage, which NaN payload ends up in a +/// channel depends on the order the compiler adds the ranks in, and a row-wise loop let it reorder +/// them (`crypto_aux_image_is_bit_exact_on_edge_values` catches that). +#[derive(Default)] +struct IdColorMemo(Vec<(u32, [f32; 3])>); + +impl IdColorMemo { + #[inline] + fn get(&mut self, rank: usize, id: f32) -> [f32; 3] { + if self.0.len() <= rank { + self.0.resize(rank + 1, (id.to_bits(), id_color(id))); + } + let e = &mut self.0[rank]; + if e.0 != id.to_bits() { + *e = (id.to_bits(), id_color(id)); + } + e.1 + } +} + /// The coverage-weighted ID colours at aux pixel `i` (the spec's preview). #[inline] -fn crypto_colors(planes: &RankPlanes, i: usize) -> [f32; 3] { +fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; 3] { let mut c = [0.0f32; 3]; - for (id, cov) in planes { + for (rank, (id, cov)) in planes.iter().enumerate() { let (Some(&id), Some(&cov)) = (id.get(i), cov.get(i)) else { continue }; - let k = id_color(id); + let k = memo.get(rank, id); for j in 0..3 { c[j] += k[j] * cov; } @@ -421,8 +445,8 @@ pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image { pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], colors: bool) -> Image { let planes = a.crypto_rank_planes(layer); let mut img = Image::new(a.width, a.height); - img.data.par_iter_mut().enumerate().for_each(|(i, px)| { - let c = if colors { crypto_colors(&planes, i) } else { [0.0; 3] }; + img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| { + let c = if colors { crypto_colors(&planes, i, memo) } else { [0.0; 3] }; *px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)]; }); img @@ -442,7 +466,7 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let display = ctx.params.e("display"); let matte_only = ctx.params.b("matteOnly"); let planes = a.crypto_rank_planes(&layer); - b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + b.img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| { let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k)); if matte_only { *px = [m, m, m, m]; @@ -451,7 +475,7 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = match display { // Colors: coverage-weighted ID colours (the spec's preview), selection brighter. 0 => { - let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k)); + let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k, memo)); let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); [c[0], c[1], c[2], 1.0] } @@ -819,6 +843,20 @@ mod tests { a } + /// The memo hands out exactly `id_color`'s bits for every rank and pixel of the edge values + /// (NaN IDs with and without a payload, -0.0 and 0.0 are keyed by their bits). + #[test] + fn id_color_memo_matches_id_color() { + let a = edge_aux(true); + let planes = a.crypto_rank_planes("CryptoObject"); + let mut memo = IdColorMemo::default(); + for i in 0..12 { + for (r, (id, _)) in planes.iter().enumerate() { + assert_eq!(memo.get(r, id[i]).map(f32::to_bits), id_color(id[i]).map(f32::to_bits), "pixel {i} rank {r}"); + } + } + } + fn edge_selection() -> Vec { vec![crypto_hash("sphere"), f32::NAN.to_bits(), 0x7fc0_1234, (-0.0f32).to_bits()] } From 17edbbfffff084155706581cbbd6e9965f13834f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Fri, 16 Oct 2026 16:43:57 +0530 Subject: [PATCH 0086/1386] Preview: an edit keeps the cached frames outside the layers it changes (#1641) The RAM preview keyed every frame by one identity of the whole comp, so any edit (a cut, a trim, a nudge) made every cached frame a miss and the whole comp recached. When only some of a comp's layers change, frames outside their In-Out span now move to the new identity. Settings changes, reorders, expressions, and edits that reach other layers (parents, track mattes, effect layer pickers) still recache everything. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/lib.rs | 55 +++++++++++++++++++++++++++--- crates/ui-egui/src/frames.rs | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..d80939f2b 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -839,7 +839,12 @@ impl Comp { /// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide /// Shy Layers and the viewer's Resolution. pub fn same_pixels(&self, o: &Comp) -> bool { - // Every field (no `..`: a new field must be considered here); the layers last. + self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + } + + /// [`Self::same_pixels`] but for the layers. + fn same_settings(&self, o: &Comp) -> bool { + // Every field (no `..`: a new field must be considered here). let Comp { width, height, @@ -849,7 +854,7 @@ impl Comp { display_start, background, work_area, - layers, + layers: _, markers, shutter_angle, shutter_phase, @@ -891,8 +896,50 @@ impl Comp { && *global_light == o.global_light && *guides == o.guides && *essential == o.essential - && layers.len() == o.layers.len() - && layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b)) + } + + /// The comp-time span (`in_point..out_point` of every layer changed, added or removed) + /// outside which `self` draws the same pixels as `o`, so cached frames there stay valid + /// (#1641). `None` when the edit can reach any frame: a comp setting, a layer reorder, an + /// expression (it reads anything), or a changed layer another layer parents to, mattes + /// with or picks in an effect. + pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> { + fn reads(g: &PropGroup, changed: &[LayerId]) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => { + p.expr.is_some() + || std::iter::once(&p.value) + .chain(p.keys.iter().map(|k| &k.value)) + .any(|v| matches!(v, Value::Layer(Some(id)) if changed.contains(&LayerId(*id)))) + } + Node::Group(g) => reads(g, changed), + }) + } + if !self.same_settings(o) { + return None; + } + let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::>(); + if common(self, o) != common(o, self) { + return None; + } + let mut changed = Vec::new(); + let mut span: Option<(Tick, Tick)> = None; + // Each changed layer comes up from both sides: its old and its new span. + for (l, other) in self.layers.iter().map(|l| (l, o.layer(l.id))).chain(o.layers.iter().map(|l| (l, self.layer(l.id)))) { + if other.is_some_and(|b| l.same_pixels(b)) { + continue; + } + changed.push(l.id); + let (s, e) = (l.in_point.min(l.out_point), l.in_point.max(l.out_point)); + span = Some(span.map_or((s, e), |(a, b)| (a.min(s), b.max(e)))); + } + let reaches = |l: &Layer| { + l.parent.is_some_and(|p| changed.contains(&p)) || l.track_matte.as_ref().is_some_and(|m| changed.contains(&m.layer)) || reads(&l.props, &changed) + }; + if self.layers.iter().chain(&o.layers).any(reaches) { + return None; + } + span } /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..24dabf48f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -227,6 +227,19 @@ impl Cache { } self.order.retain(|k| !gone.contains(&k.content)); } + /// Move the frames of identity `from` that `keep` accepts to identity `to` (in place in the + /// eviction order), unless `to` already has them. + fn carry(&mut self, from: u64, to: u64, keep: impl Fn(i64) -> bool) { + for k in self.order.iter_mut().filter(|k| k.content == from && keep(k.frame)) { + let moved = FrameKey { content: to, ..*k }; + if !self.map.contains_key(&moved) + && let Some(img) = self.map.remove(k) + { + self.map.insert(moved, img); + *k = moved; + } + } + } fn evict_to_budget(&mut self) { while self.bytes > self.budget { let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break }; @@ -731,6 +744,21 @@ impl Frames { { return (*base_id, Some(base.clone())); } + // Only layers of this comp changed: its frames outside their span (a frame either side + // for motion blur) keep drawing the same pixels, so a cut or trim recaches only the + // layers it touched (#1641). + if let Some((base_id, base)) = bases.get(&comp.0) + && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) + && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b), + _ => Arc::as_ptr(c), + }) == *base_id + && let Some((s, e)) = new.changed_span(old) + && let Ok(mut cache) = self.cache.lock() + { + let (first, last) = (new.frame_rate.frame_at(s).saturating_sub(1), new.frame_rate.frame_at(e).saturating_add(1)); + cache.carry(*base_id, plain, |f| f < first || f > last); + } if bases.len() > MAX_KEEPERS { bases.clear(); } @@ -1990,6 +2018,43 @@ mod tests { assert_ne!(at(&s), o1); } + /// A cut or trim recaches only the frames of the layers it touched (#1641), and none are + /// kept when the change reaches other layers (here a child of the trimmed layer). + #[test] + fn an_edit_keeps_the_frames_outside_the_layers_it_changed() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp")); + let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer"); + let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap(); + s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap(); + let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap(); + let frames: Vec = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect(); + let f = Frames::default(); + let cache_all = |s: &Session| { + let c = f.content_of(&s.project, s.revision, comp); + frames.iter().for_each(|&n| insert(&f, key(c, n, 0))); + c + }; + let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::>(); + let before = cache_all(&s); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap(); + let after = f.content_of(&s.project, s.revision, comp); + assert_ne!(after, before); + let kept: Vec = (fr.frame_at(out) + 2..frames.len() as i64).collect(); + assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay"); + + s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap(); + let parented = cache_all(&s); + s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap(); + assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::::new(), "the trimmed layer's child moves everywhere"); + assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo"); + } + #[test] fn markers_keep_the_frames() { use effectcraft_engine::Session; From 7b3d3455cb7bb66ddf30ac2c4af0edfdfbf883b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:35:07 +1100 Subject: [PATCH 0087/1386] UI: rescan system fonts from the font menu; Linux scan smoke test (#1089) Fonts installed after EffectCraft launched (dnf, GNOME Font Viewer, a drop into ~/.local/share/fonts) stayed invisible until restart: only the startup background scan ran, and the report in #1089 could not tell discovery gaps apart from a stale scan. - The font menu's filter row gains a Rescan Fonts button (Character and Properties panels share the popup) that runs the existing text.fonts {rescan} command and reports the number added in the status line. The press doesn't flip the Variable-fonts filter (the button is above the row's click area and closes the popup on top of it). - system_scan_finds_fonts now asserts, on a native fontconfig desktop (any machine with /etc/fonts, which includes the Fedora reporter's and the CI runner), that the scan lists installed families rather than silently returning only the bundled fonts: 870 faces in ~10 ms on an Arch box. Tests: ui_font_search 4 passed (new rescan test drives the real popup); effectcraft-text lib system scan green; fmt and clippy --all-targets -D warnings clean. Translations for 'Rescan Fonts' in all six catalogs. --- crates/text/src/fonts.rs | 5 +++++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/panels/text_panels.rs | 11 +++++++++++ crates/ui-egui/tests/ui_font_search.rs | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..98ed85493 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -943,6 +943,11 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { + // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must + // list the installed system families, not just the bundled three. + assert!(n > 20, "fontconfig system fonts missing: {n}"); + } if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 58cbe8349..d554c45fa 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -351,6 +351,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -893,6 +894,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1626,6 +1628,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -2368,6 +2371,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2895,6 +2899,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3349,6 +3354,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3926,6 +3932,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..631a75eba 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -642,6 +642,17 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } + // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear + // without restarting EffectCraft: the system scan also runs in the background at + // startup, this re-runs it on demand. + let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { + match app.session.execute("text.fonts", json!({"rescan": true})) { + Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), + Err(e) => app.ui.status = e.to_string(), + } + } + app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); let mut seen = std::collections::HashSet::new(); let filtered: Vec<_> = rows diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index 6be69cb7a..0d9c7b79a 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -176,3 +176,24 @@ fn font_menu_filters_variable_fonts() { key(&mut h, Key::Escape); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} From f676bf08204dc261e9b8f8d978307a0d538e8022 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 22:20:10 +1100 Subject: [PATCH 0088/1386] Keep the font rescan off the filter label and off the UI thread. The 180 px font menu pinned Rescan Fonts over "Variable fonts only", and the click walked every fontconfig directory on the frame thread. The button is now its own row, and the scan runs on a background thread that the frame polls. The Linux face-count assert is left to the native-fonts gate. --- crates/text/src/fonts.rs | 64 +++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 29 +++++++---- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 98ed85493..4f6dceb13 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -360,7 +360,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner()); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -368,6 +369,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -943,11 +998,8 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); - if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { - // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must - // list the installed system families, not just the bundled three. - assert!(n > 20, "fontconfig system fonts missing: {n}"); - } + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..108c6db23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1871,6 +1871,7 @@ impl eframe::App for EffectcraftApp { // Settings ▸ General ▸ Language instead of only the first frame. let language = crate::i18n::language(self); crate::i18n::set_current(language); + crate::panels::text_panels::poll_font_rescan(self, ctx); if !self.styled || self.styled_language != language { theme::install(ctx, &self.tokens, language); self.styled_language = language; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 631a75eba..c6ade5634 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -568,6 +568,17 @@ struct FontSearch { selected: usize, } +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + /// Shared font menu for Character and Properties: search, recent fonts and previews. pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); @@ -642,15 +653,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } - // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear - // without restarting EffectCraft: the system scan also runs in the background at - // startup, this re-runs it on demand. - let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); - if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { - match app.session.execute("text.fonts", json!({"rescan": true})) { - Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), - Err(e) => app.ui.status = e.to_string(), - } + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app.ui.status = format!("{}…", tr("Rescan Fonts")); } app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); @@ -683,7 +692,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } // Give the result list its actual height even when the previous query had // only one row. A cached tiny Area must not collapse a reopened long list. - let filter_room = filter_h + ui.spacing().item_spacing.y; + let filter_room = filter_h + rescan_h + ui.spacing().item_spacing.y * 2.0; let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0)); // A minimum, from below the filter row (a maximum would move the cursor back up). ui.set_min_height(height); From e6d5297a86cf16140b50048d3b10a3d5f5dac2e0 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:16:36 +0200 Subject: [PATCH 0089/1386] UI: the menu bar browses from the press until the release A title opens on the press and the menus follow the cursor while the button is held: hovering a category opens its submenu and releasing over an entry picks it. An outside press or a focus loss closes the open menu again. --- crates/ui-egui/src/menu_keys.rs | 39 ++++++++- crates/ui-egui/src/menus.rs | 134 ++++++++++++++++++++++++++++--- crates/ui-egui/tests/ui_menus.rs | 128 +++++++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 397385309..9477e142b 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -10,7 +10,7 @@ //! shortcuts and the panels don't see them (Space must not start a preview). use egui::containers::menu::{MenuState, SubMenu, find_menu_root}; -use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui}; +use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Rect, Response, Ui}; /// An entry of an open menu as drawn this frame. #[derive(Clone, Copy)] @@ -39,6 +39,12 @@ pub struct Nav { tops: Vec<(Id, Id)>, /// Open menus by depth: the top-level menu, then each open submenu. levels: Vec, + /// The titles and open menus drawn this frame: a press outside them is an outside press, + /// which closes the menu (native behaviour; the press itself still reaches what's under it). + rects: Vec, + /// A title press opened (or kept) the menu this frame: releasing the button over an entry + /// chooses it (After Effects' press-and-browse). + browsing: bool, } /// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the @@ -73,6 +79,37 @@ impl Nav { self.tops.push((button.id, Popup::default_response_id(button))); } + /// The rect `rect` (a menu title or an open menu) holds, so a press there doesn't close. + pub fn cover(&mut self, rect: Rect) { + self.rects.push(rect); + } + + /// Whether `pos` is inside a title or menu drawn this frame. + pub fn covers(&self, pos: Pos2) -> bool { + self.rects.iter().any(|r| r.contains(pos)) + } + + /// A title press is in progress (its button still held): see [`Nav::browsing`]. + pub fn set_browsing(&mut self) { + self.browsing = true; + } + + /// Whether the menu bar is mid press-and-browse (a title was pressed this pointer-down and + /// hasn't been released yet): releasing over an entry then chooses it. + pub fn browsing(&self) -> bool { + self.browsing + } + + /// The popup of the open top-level menu (only one can be open), if any. + pub fn open_popup(&self, ctx: &Context) -> Option { + self.tops.iter().find(|(_, p)| Popup::is_id_open(ctx, *p)).map(|(_, p)| *p) + } + + /// The position in the bar of the open top-level menu, if any. + pub fn open_index(&self, ctx: &Context) -> Option { + self.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p)) + } + /// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves /// the keyboard highlight to it, and a highlighted entry scrolls into view. pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b1eba4f0c..dbdd32e39 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1974,32 +1974,118 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } } +/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last +/// frame (its title's open look, and the hover that switches between the titles). +fn open_menu_id() -> egui::Id { + egui::Id::new("menu-bar-open") +} + +/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse). +fn browse_menu_id() -> egui::Id { + egui::Id::new("menu-bar-browse") +} + /// Draw the in-window menu bar (the engine's AE menu tree). +/// +/// The titles open on the press, not the release (native menu bars): while one is held the menus +/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another +/// title switches the open menu to it, and the open one closes on a press outside it or when the +/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the +/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to +/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the +/// menu. The closing press itself still reaches the widget under it, as a click does in After +/// Effects. pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let mut clicked: Option<(String, Value)> = None; let mut nav = crate::menu_keys::Nav::default(); + // The open menu as of the last frame: the titles are drawn before this frame can open or + // close anything, and what happened last frame is what they must react to. + let open_prev: Option = ctx.data(|d| d.get_temp(open_menu_id())).flatten(); + if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) { + nav.set_browsing(); + } + let mut drawn = 0; ui.horizontal_centered(|ui| { ui.add_space(6.0); egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| { for node in effectcraft_engine::menus::menu_bar() { - if let MenuNode::Submenu { label, children } = node { - let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { + let MenuNode::Submenu { label, children } = node else { continue }; + // The open title takes the open button's look while its menu is up (as in AE). + let inactive = ui.style().visuals.widgets.inactive; + if open_prev == Some(drawn) { + ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; + } + let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label))); + ui.style_mut().visuals.widgets.inactive = inactive; + // A press on a title opens its menu at once (a second press on the open one closes + // it), and while it is held the menus below are browsable. The keyboard and the + // accessibility activation report `clicked` without the primary button (the press + // then happened somewhere else), and toggle the menu. Hovering another title + // switches to it once the pointer has moved: merely resting there must not take a + // keyboard-driven menu over. + let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + // With the button down elsewhere nothing is hovered, so the title under the + // pointer takes the highlight by hand: the hover-switch to it shows, one frame + // later like egui's own hover (the open title keeps its open look). + if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) { + response = response.highlight(); + } + let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered(); + let cmd = if pressed { + nav.set_browsing(); + Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn))) + } else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) { + Some(egui::SetOpenCommand::Toggle) + } else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) { + Some(egui::SetOpenCommand::Bool(true)) + } else { + None + }; + let inner = egui::Popup::menu(&response) + .close_behavior(egui::PopupCloseBehavior::IgnoreClicks) + .style(crate::widgets::menu_style) + .open_memory(cmd) + .show(|ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); }); - nav.top(&r.response); - app.auto.add(&format!("menu.{label}"), r.response.rect, label); + nav.top(&response); + // The titles and the open menus: a press inside them is not an outside press. + nav.cover(response.rect); + if let Some(inner) = &inner { + nav.cover(inner.response.rect); } + app.auto.add(&format!("menu.{label}"), response.rect, label); + drawn += 1; } }); }); crate::menu_keys::handle(&ctx, &nav); + // egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press + // outside them (the press itself still reaches the widget below) and when the window loses + // focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires + // the cursor-left event and the platform forwards none of its clicks to egui, so there is no + // click to close on — and a graze or a stay must not drop the menu, as in native apps. + if let Some(top) = nav.open_popup(&ctx) { + let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false)))); + let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p))); + if focus_lost || outside_press { + egui::Popup::close_id(&ctx, top); + } + } if let Some((id, params)) = clicked && let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params }) { app.ui.status = e; } + // What the next frame starts from: the open title, and a title press still in progress. + let open = nav.open_index(&ctx); + let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down()); + ctx.data_mut(|d| { + d.insert_temp(open_menu_id(), open); + d.insert_temp(browse_menu_id(), browsing); + }); } /// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context @@ -2051,9 +2137,10 @@ fn menu_nodes( Some(t) => r.on_hover_text(t), None => r, }; + let r = browse_hover(nav, r); app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label); nav.entry(ui, depth, &r, false); - if r.clicked() { + if r.clicked() || browse_pick(nav, ui, &r) { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); } @@ -2065,19 +2152,27 @@ fn menu_nodes( // every other submenu goes through the menu catalog. let shown = crate::i18n::effect_category(children, label) .unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[])))); - let r = ui.menu_button((gutter(false), shown.as_str()), |ui| { + let browsing = nav.browsing(); + let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); }); - nav.entry(ui, depth, &r.response, true); - if r.inner.is_some() { - nav.opened(depth, &r.response); + let response = browse_hover(nav, response); + nav.entry(ui, depth, &response, true); + if inner.is_some() { + // `ui.menu_button` drops the popup's own response on the floor: read it back, + // so that a press inside this submenu doesn't count as an outside press. + let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id); + if let Some(resp) = ui.ctx().read_response(sub) { + nav.cover(resp.rect); + } + nav.opened(depth, &response); } } MenuNode::Item(e) => { - let r = menu_entry(app, ui, e); + let r = browse_hover(nav, menu_entry(app, ui, e)); nav.entry(ui, depth, &r, false); - if r.clicked() { + if r.clicked() || browse_pick(nav, ui, &r) { *clicked = Some((e.command.clone(), e.params.clone())); ui.close(); } @@ -2086,6 +2181,23 @@ fn menu_nodes( } } +/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports +/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row +/// under the pointer takes the highlight by hand. It reads as the hover look one frame later, +/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native +/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses. +fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response { + if nav.browsing() && r.contains_pointer() { r.highlight() } else { r } +} + +/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which +/// then chooses it like a click does. egui reports neither for it: the release is far from the +/// press, so there is no click, and nothing but the pressed widget is hovered while a button is +/// down, so the row has to be tested by the pointer being over it. +fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool { + nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) +} + /// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu, /// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`. pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) { diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..a59599dfd 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -29,6 +29,25 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(2); } +/// Press the primary button at `p` and keep it down. +fn press(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Move the pointer to `p` (press or no press, whichever is held). +fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(2); +} + +/// Release the primary button at `p`. +fn release(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + /// Press and release `key` (one frame each), then let the UI settle. fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); @@ -244,3 +263,112 @@ fn space_previews_after_opening_the_demo_from_home() { key(&mut h, Key::Space); assert!(h.state().playback.playing, "Space previews the demo (#1059)"); } + +/// The titles open on the press, not the release, so the menus can be browsed with the button +/// still held down: moving onto an entry and releasing there chooses it (as in After Effects). +#[test] +fn a_menu_opens_on_press_and_browsing_releases_choose_an_entry() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + press(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu opens on the press, before any release"); + let item = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + move_to(&mut h, item.center()); + release(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the release over the entry chose it"); + assert!(!shown(&h, "Quick Apply"), "and closed the menu"); +} + +/// While a menu is open, hovering another title switches to that menu (native menu bars). +#[test] +fn hovering_another_title_while_a_menu_is_open_switches_to_it() { + let mut h = harness(); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open"); + let file = rect(&h, "menu.File"); + move_to(&mut h, file.center()); + assert!(shown(&h, "Open Project"), "hovering File opened the File menu"); + assert!(!shown(&h, "Keyboard Shortcuts"), "and closed Edit"); +} + +/// The press on the title of the open menu closes it again, and the click's release — a plain +/// click on the title — leaves it closed. +#[test] +fn pressing_the_open_title_again_closes_its_menu() { + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project"), "the File menu is open"); + click_at(&mut h, file.center()); + assert!(!shown(&h, "Open Project"), "the second press closed it"); +} + +/// Press-and-hold browse (AE's gesture) reaches into a submenu: while the button is still held, +/// moving onto the submenu row opens it, and the release inside the submenu picks the entry. +#[test] +fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() { + let mut h = harness(); + // Applying an effect needs a selected layer, as in the app after clicking one in the timeline. + let first = { + let cid = h.state().session.active_comp_id().expect("the demo opens a comp"); + let comp = h.state().session.project.comp(cid).expect("the demo comp exists"); + comp.layers.iter().find(|l| l.effects().is_some()).expect("the demo comp has a layer that takes effects").id + }; + h.state_mut().session.state.selected_layers = vec![first]; + let effect = rect(&h, "menu.Effect"); + press(&mut h, effect.center()); + assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press"); + // The categories come from the effect registry; the first alphabetically is "3D Channel". + let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect(); + move_to(&mut h, cat.center()); + assert!(shown(&h, "Cryptomatte"), "moving onto a category while still holding opened its submenu"); + let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect(); + move_to(&mut h, entry.center()); + release(&mut h, entry.center()); + assert!(!shown(&h, "Effect Controls"), "the release closed the menus"); + assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect"); +} + +/// The open menus close on a press outside them and when the window loses focus. The pointer +/// leaving the window — onto the OS title bar, whose clicks are never forwarded to egui — closes +/// nothing, however long it stays away: there is no click to close on, and a graze or a stay +/// must not drop the menu. The closing press itself is not swallowed: it reaches the widget under +/// it, as a click does in After Effects. +#[test] +fn outside_presses_and_focus_loss_close_the_menus() { + let mut h = harness(); + let file = rect(&h, "menu.File"); + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + // A press in the empty part of the bar, outside every title and menu. + click_at(&mut h, pos2(1100.0, 12.0)); + assert!(!shown(&h, "Open Project"), "a press outside the menus closes them"); + // The press reaches the header's workspace tab behind the open menu: the menu closes and the + // tab still acts (the menus don't swallow input). + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + let tab = rect(&h, "header.workspace.Review"); + click_at(&mut h, tab.center()); + assert_eq!(h.state().ui.workspace, "Review", "the press reached the tab behind the open menu"); + assert!(!shown(&h, "Open Project"), "and closed the menu"); + // Moving onto the OS title bar fires the cursor-left event — its clicks are never forwarded + // to egui — which egui reports as `PointerGone`. It must not close the menus, however long + // the pointer stays away. + click_at(&mut h, file.center()); + assert!(shown(&h, "Open Project")); + h.input_mut().events.push(Event::PointerGone); + h.run_steps(1); + assert!(shown(&h, "Open Project"), "leaving the window alone doesn't drop the menus at once"); + let now = h.ctx.input(|i| i.time); + h.input_mut().time = Some(now + 60.0); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "staying away doesn't close them either"); + h.input_mut().events.push(Event::PointerMoved(file.center())); + h.run_steps(2); + assert!(shown(&h, "Open Project"), "the pointer coming back keeps them open too"); + // Losing focus (alt-tabbing away, or clicking another window). + h.input_mut().events.push(Event::WindowFocused(false)); + h.run_steps(2); + assert!(!shown(&h, "Open Project"), "losing focus closes the menus"); +} From b7ae6603792cc14a1663aa9f241d5f34964577c7 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:16:36 +0200 Subject: [PATCH 0090/1386] UI: a menu scrolls from its edge while browsing with the button held Resting the pointer in an 18 pt band at a menu's bottom edge crawls the list after a short delay and speeds up the longer it stays there, so long menus stay reachable without letting go of the press-and-hold gesture. Context menus do not crawl. --- crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/panels/timeline.rs | 8 +-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/src/widgets.rs | 73 ++++++++++++++++++++++++++- crates/ui-egui/tests/ui_scrolling.rs | 73 +++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dbdd32e39..5be1e80a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2048,7 +2048,8 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { .open_memory(cmd) .show(|ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); + let browsing = nav.browsing(); + crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0)); }); nav.top(&response); // The titles and the open menus: a press inside them is not an outside press. @@ -2155,7 +2156,7 @@ fn menu_nodes( let browsing = nav.browsing(); let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); - crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); + crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); }); let response = browse_hover(nav, response); nav.entry(ui, depth, &response, true); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2de97a4b8..d9c5b2078 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2605,7 +2605,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for (label, text) in items { if ui.button(label).clicked() { picked = Some(text); @@ -3926,7 +3926,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for m in BlendMode::ALL { item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); if m.ends_group() { @@ -3960,11 +3960,11 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut } } cats.sort_by_key(|(c, _)| c.to_ascii_lowercase()); - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); ui.menu_button(cat, |ui| { - widgets::menu_scroll(ui, |ui| { + widgets::menu_scroll(ui, false, |ui| { for e in fx { item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 1064854f0..2eee93dde 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1251,7 +1251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button(crate::i18n::tr("Select"), |ui| { - crate::widgets::menu_scroll(ui, |ui| { + crate::widgets::menu_scroll(ui, false, |ui| { for (id, name) in &hits { if ui.button(name).clicked() { context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..7aee3d0be 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -607,9 +607,78 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool { /// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one /// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view /// (#191). -pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { +/// +/// While the bar is mid press-and-browse (`browsing`), resting the pointer against the menu's +/// bottom or top edge scrolls it from there — a crawl at first, then a run, as native menus do — +/// so a long list can be walked with no wheel. Without the hold the edges do nothing: the wheel +/// and the scroll bar are how a normally opened menu scrolls. +pub fn menu_scroll(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R { let max_h = ui.ctx().content_rect().height() - 40.0; - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner + let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add); + edge_scroll(ui, &out, browsing); + out.inner +} + +/// The edge scroll [`menu_scroll`] adds while browsing: with the pointer resting in the bottom +/// (top) band of an overflowing menu, its content crawls up (down) after a short pause, ramps to +/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold. +/// +/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new +/// offset goes into the area's state and shows on the next frame: a frame of lag, invisible at +/// these speeds. egui's own scrolling (wheel, drag, scroll bar) is untouched. +fn edge_scroll(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput, browsing: bool) { + /// How far from the top / bottom edge the pointer starts the crawl (points). + const BAND: f32 = 18.0; + /// The pause before the crawl starts: a pointer merely grazing the edge must not jump. + const DELAY: f32 = 0.12; + /// Points per second: a crawl at first, reaching the run about three quarters of a second in. + const CRAWL: f32 = 45.0; + const RAMP: f32 = 700.0; + const RUN: f32 = 560.0; + + let ctx = ui.ctx(); + let over = out.content_size.y - out.inner_rect.height(); + let dir = match (browsing, over > 0.0, ctx.input(|i| i.pointer.latest_pos())) { + (true, true, Some(p)) if out.inner_rect.contains(p) => { + if p.y >= out.inner_rect.max.y - BAND { + 1 + } else if p.y <= out.inner_rect.min.y + BAND { + -1 + } else { + 0 + } + } + _ => 0, + }; + // The clock the pause counts from, kept per menu: entering or leaving an edge band restarts + // it, so a later stay there crawls off again instead of jumping straight to the run. + let now = ctx.input(|i| i.time); + let key = out.id.with("menu-edge-scroll"); + let (since, prev) = ctx.data(|d| d.get_temp::<(f64, i32)>(key)).unwrap_or((now, 0)); + let since = if dir == prev { + since + } else { + ctx.data_mut(|d| d.insert_temp(key, (now, dir))); + now + }; + if dir == 0 { + return; + } + if now - since < f64::from(DELAY) { + // A pointer resting still fires no events: keep the frames coming until the crawl runs. + ctx.request_repaint(); + return; + } + let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN); + let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32; + let new = (out.state.offset.y + crawl).clamp(0.0, over); + if new != out.state.offset.y { + let mut state = out.state; + state.offset.y = new; + state.store(ctx, out.id); + // At the end of the list the crawl stands still and asks for no more frames. + ctx.request_repaint(); + } } /// egui's menu style with no gap between the rows: the gap the app's item spacing left between diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 5f6816230..a0bc1fe67 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -40,6 +40,33 @@ fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { h.run_steps(20); } +/// Press the primary button at `p` and keep it down (press-and-browse, as on a menu title). +fn hold(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(2); +} + +/// Move the pointer to `p`, keeping whatever button is held. +fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.run_steps(2); +} + +/// The open menu popup's rect (frame included), from egui's area memory: the only tall area up. +fn open_popup(h: &Harness<'_, EffectcraftApp>) -> Rect { + h.ctx + .memory(|m| { + m.areas() + .visible_layer_ids() + .into_iter() + .filter(|l| l.order != egui::Order::Background) + .filter_map(|l| m.area_rect(l.id)) + .find(|r| r.height() > 100.0) + }) + .unwrap_or_else(|| panic!("no menu popup is open")) +} + /// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always /// shows) so its last entries can be reached and clicked, instead of running off the bottom of /// the window (#269). @@ -59,6 +86,52 @@ fn a_menu_taller_than_the_window_scrolls() { assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click"); } +/// With the title still held down (press-and-browse), resting the pointer against the bottom +/// edge of a menu too tall for the window scrolls it from there — a crawl, then a run, as native +/// menus do — so the long list can be walked with no wheel. +#[test] +fn holding_the_title_scrolls_the_menu_from_its_bottom_edge() { + let mut h = harness(1200.0, 520.0); + let edit = rect(&h, "menu.Edit"); + hold(&mut h, edit.center()); + let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu opens on the press").rect(); + assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})"); + let menu = open_popup(&h); + // Creeping down the edge: the 18 pt band above the menu's inner bottom lies at one of these + // positions, whatever the frame's margin (they are 6 pt apart). + let mut walked = false; + for step in 0..5 { + move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32)); + h.run_steps(8); + let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + if now.min.y < before.min.y { + walked = true; + break; + } + } + assert!(walked, "resting on the bottom edge of the held-down menu scrolled its entries up"); +} + +/// Without the title held down — the menu opened by a plain click — the same edge rest scrolls +/// nothing: the wheel (above) is how a click-opened menu scrolls, the pointer alone must not +/// move it (a hover may align the row under it by a few points; a crawl there would be a bug). +#[test] +fn without_the_hold_the_edge_does_not_scroll_the_menu() { + let mut h = harness(1200.0, 520.0); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})"); + let menu = open_popup(&h); + for step in 0..5 { + move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32)); + h.run_steps(8); + let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + let crawled = before.min.y - now.min.y; + assert!(crawled < 60.0, "step {step}: the edge scrolled the menu with no title held down ({before:?} -> {now:?})"); + } +} + fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { h.input_mut().events.push(Event::PointerMoved(from)); h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); From 912f828aa3e66cbc3ec3ad41996bb3dec800ea9d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:47 +0300 Subject: [PATCH 0091/1386] project: output_size never asks for sides past the comp limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 30 000 px comp (the repaired MAX_SIZE on load) at resolution 4.0 produced 120 000 px sides, which overflow u32 in the exporters' w * h * 4 (debug panic, release wrap then slice panic) after already requesting a ~57 GB frame. The resize path in the same file clamps to 30 000; output_size now does too. Regression test: output_size_never_exceeds_the_comp_limit — on main it returns (120000, 120000); with the clamp (30000, 30000), through both RenderSettings and the OutputModule wrapper. --- crates/project/src/render_queue.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..e49c5d1cc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -444,7 +444,11 @@ impl RenderSettings { } pub fn output_size(&self, comp: &Comp) -> (u32, u32) { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing + // `w * h * 4` in the exporters. The resize path clamps to the same limit. + let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); + let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -1663,6 +1667,16 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). + let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + assert_eq!(rs.output_size(&c), (30_000, 30_000)); + let m = OutputModule::for_format(OutputFormat::PngSequence); + assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); From 30e02b06caef9ca3d6bfc672bbd4110204d86db4 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:46:24 +0300 Subject: [PATCH 0092/1386] project: output_size clamps the scale, not the sides, and every renderer uses it Clamping each side on its own broke the aspect ratio (a 30000x15000 comp at 4x came out 30000x30000). The effective scale min(s, MAX_SIZE/w, MAX_SIZE/h) keeps both sides within the comp limit, and the export pipeline and the locked-aspect width derivation render at that same scale, so nothing requests a frame larger than output_size reports. --- crates/engine/src/commands/render_queue.rs | 2 +- crates/export/src/pipeline.rs | 4 +-- crates/project/src/render_queue.rs | 37 ++++++++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 09a7c7d2f..39af29c42 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -601,7 +601,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c7..6877a09bf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -147,7 +147,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -231,7 +231,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index e49c5d1cc..44a7be5a4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,12 +442,17 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing - // `w * h * 4` in the exporters. The resize path clamps to the same limit. - let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); - let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); (w, h) } pub fn resolution_label(&self) -> &'static str { @@ -1120,7 +1125,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1669,12 +1674,24 @@ mod tests { #[test] fn output_size_never_exceeds_the_comp_limit() { - // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). - let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. let rs = RenderSettings { resolution: 4.0, ..Default::default() }; - assert_eq!(rs.output_size(&c), (30_000, 30_000)); let m = OutputModule::for_format(OutputFormat::PngSequence); - assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); } #[test] From 1f81c813e1c2f427c2567a305af99cfa8ade8a31 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Fri, 16 Oct 2026 18:45:53 +0530 Subject: [PATCH 0093/1386] #1641: keep RAM frames when adding or removing sources Validate the previous content identity using the selected comp's layers as well as its address. Otherwise a new solid changes the reachable source set and prevents all frame carry. Read sources removed by undo from the saved base snapshot, while still checking current values of existing dependencies, project settings and nested comps. Add regressions for short-solid add/delete, addition undo/redo, and simultaneous edits to existing solid or nested-comp dependencies. Both the addition and undo regressions failed before their fixes. Validation on macOS: - Frame-cache tests: 23 passed. - cargo xtask ci: passed (3452 tests passed, 31 ignored), including workspace release Clippy, tests, docs, PDF deps, layers, assets and wasm. Local release builds used LTO=false and codegen-units=16; repository profile settings are unchanged. - Release UI driver: cut, move, add and delete; all 15 cache audits pass. Adding/deleting a 2-3s solid retains 567/600 frames (only 59-91 recache). Undo/redo immediately restores all 600 frames. - All 4,800 CPU cached-vs-fresh frame comparisons match at Quarter preview resolution; four full-comp playback sweeps have zero waiting steps. --- crates/ui-egui/src/frames.rs | 84 ++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 24dabf48f..039955fc5 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -301,7 +301,7 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) /// hands out), and by value the project settings and the footage, solids and proxies it uses. /// With expressions in its comps (which can read anything) every item counts. pub fn comp_content(project: &Project, comp: ItemId) -> u64 { - comp_content_with(project, comp, |_, c| Arc::as_ptr(c)) + comp_content_with(project, comp, project, |_, c| c) } /// Whether two versions of a comp draw the same pixels. Markers only count when an expression @@ -321,8 +321,9 @@ fn project_has_expressions(project: &Project) -> bool { project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props)))) } -/// [`comp_content`] with each comp's address given by `addr`. -fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc) -> *const Comp) -> u64 { +/// [`comp_content`] with each comp's version selected for both its sources and its address. +/// Sources removed by undo are read from `base`. +fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc) -> &'a Arc) -> u64 { use std::hash::{Hash, Hasher}; fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { @@ -332,6 +333,7 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc } let mut h = std::collections::hash_map::DefaultHasher::new(); format!("{:?}", project.settings).hash(&mut h); + let item = |id| project.item(id).or_else(|| base.item(id)); let mut seen = BTreeSet::new(); let mut stack = vec![comp]; let mut expressions = false; @@ -339,7 +341,8 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc if !seen.insert(id) { continue; } - let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue }; + let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue }; + let c = version(id, c); for l in &c.layers { if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { stack.push(item); @@ -349,14 +352,14 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc } let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { - let Some(it) = project.item(id) else { continue }; + let Some(it) = item(id) else { continue }; id.0.hash(&mut h); if expressions { // (`comp("Name")`, `footage("Name")`) it.name.hash(&mut h); } match &it.kind { - ItemKind::Comp(c) => (addr(id, c) as usize).hash(&mut h), + ItemKind::Comp(c) => (Arc::as_ptr(version(id, c)) as usize).hash(&mut h), k => format!("{k:?}").hash(&mut h), } format!("{:?}", it.proxy).hash(&mut h); @@ -737,9 +740,9 @@ impl Frames { let markers_count = project_has_expressions(project); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain - && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b), - _ => Arc::as_ptr(c), + && comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) { + Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + _ => c, }) == *base_id { return (*base_id, Some(base.clone())); @@ -749,9 +752,9 @@ impl Frames { // layers it touched (#1641). if let Some((base_id, base)) = bases.get(&comp.0) && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) - && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { - Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b), - _ => Arc::as_ptr(c), + && comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) { + Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b, + _ => c, }) == *base_id && let Some((s, e)) = new.changed_span(old) && let Ok(mut cache) = self.cache.lock() @@ -2055,6 +2058,63 @@ mod tests { assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo"); } + #[test] + fn short_solid_additions_and_removals_keep_unaffected_frames() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let comp = ItemId(s.execute("comp.new", json!({"width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let f = Frames::default(); + let cache_all = |s: &Session| { + let content = f.content_of(&s.project, s.revision, comp); + (0..600).for_each(|n| insert(&f, key(content, n, 0))); + }; + let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0)); + let kept: Vec = (0..=58).chain(92..600).collect(); + cache_all(&s); + let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap(); + assert_eq!(cached(&s), kept, "adding a source invalidates only frames 59..=91"); + cache_all(&s); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cached(&s), (0..600).collect::>(), "undoing the addition restores every frame"); + s.execute("edit.redo", json!({})).unwrap(); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(cached(&s), (0..600).collect::>(), "redoing the addition restores every frame"); + s.execute("edit.clear", json!({"layers": [solid]})).unwrap(); + assert_eq!(cached(&s), kept, "removing its layer invalidates the same span"); + } + + #[test] + fn adding_a_source_does_not_hide_changes_to_existing_dependencies() { + use effectcraft_engine::Session; + use serde_json::json; + for (command, params) in [ + ("layer.settings", json!({"comp": "Outer", "layer": "Underlay", "color": "#0000ff"})), + ("prop.set", json!({"comp": "Inner", "layer": "Inner solid", "path": "transform/opacity", "value": 40})), + ] { + let mut s = Session::default(); + let comp = ItemId( + s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap(), + ); + s.execute("layer.newSolid", json!({"name": "Underlay", "color": "#ff0000"})).unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 20})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Inner solid", "color": "#00ff00"})).unwrap(); + s.execute("comp.open", json!({"comp": "Outer"})).unwrap(); + s.execute("layer.addItem", json!({"item": "Inner"})).unwrap(); + let f = Frames::default(); + let before = f.content_of(&s.project, s.revision, comp); + (0..600).for_each(|n| insert(&f, key(before, n, 0))); + s.execute(command, params).unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap(); + let after = f.content_of(&s.project, s.revision, comp); + assert!(f.cached_frames(&key(after, 0, 0)).is_empty(), "{command} changes pixels outside the added layer"); + } + } + #[test] fn markers_keep_the_frames() { use effectcraft_engine::Session; From 0502d2f82d93ec7ec324ceac2010c94a0a45daa1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 16:06:15 +0200 Subject: [PATCH 0094/1386] Auto-orient: keep the last heading when an expression-driven Position stops (fixes #1650) --- crates/render/src/three_d/camera.rs | 47 +++++++++++++++++ crates/render/src/three_d/tests_orient.rs | 64 +++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5e1f983b2..553365e00 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -241,6 +241,53 @@ fn path_tangent(ctx: &EvalCtx, layer: &Layer) -> Option { .filter(|k| **k <= ctx.time) .find_map(|&k| dir(pos(k) - pos(k - dt))) .or_else(|| keys.iter().filter(|k| **k > ctx.time).find_map(|&k| dir(pos(k + dt) - pos(k)))) + .or_else(|| { + // An expression has no keys to fall back on: sample it instead. + let expr = dims.iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression())); + expr.then(|| sampled_tangent(ctx, layer, &pos, dt, true).or_else(|| sampled_tangent(ctx, layer, &pos, dt, false))).flatten() + }) +} + +/// Samples spent on each phase of [`sampled_tangent`]: it runs per layer per frame. +const MOTION_SEARCH_STEPS: u32 = 12; + +/// Direction of the motion nearest to the context time, before it (`earlier`) or after it, for a +/// position with no keyframes to fall back on (an expression). Steps 1, 2, 4, ... frames away +/// (within the layer's in/out points) until the position differs from the current one, then +/// bisects down to `dt` the moment the motion stopped (or started), so the heading kept is the +/// one at the end of the motion whichever frame is shown. +fn sampled_tangent(ctx: &EvalCtx, layer: &Layer, pos: &dyn Fn(effectcraft_time::Tick) -> Vec3, dt: effectcraft_time::Tick, earlier: bool) -> Option { + let here = pos(ctx.time); + let moved = |t| (pos(t) - here).length() > 1e-9; + let limit = if earlier { layer.in_point.min(ctx.time) } else { layer.out_point.max(ctx.time) }; + let mut step = ctx.comp.frame_duration().max(dt); + let (mut still, mut away) = (ctx.time, None); + for _ in 0..MOTION_SEARCH_STEPS { + let t = if earlier { (ctx.time - step).max(limit) } else { (ctx.time + step).min(limit) }; + if moved(t) { + away = Some(t); + break; + } + still = t; + if t == limit { + break; + } + step = step + step; + } + let mut away = away?; + for _ in 0..MOTION_SEARCH_STEPS { + if (still - away).abs() <= dt { + break; + } + let mid = away + (still - away).mul_ratio(1, 2); + if moved(mid) { + away = mid; + } else { + still = mid; + } + } + let d = if earlier { here - pos(away) } else { pos(away) - here }; + (d.length() > 1e-9 && d.length().is_finite()).then(|| d.normalize()) } /// Auto-Orient ▸ Orient Along Path for 2D layers: extra Z rotation in degrees. diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index 06f2599dc..f9c72e296 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -8,6 +8,7 @@ use effectcraft_project::{AutoOrient, Comp, ItemId, ItemKind, Layer, LayerSource use effectcraft_time::{FrameRate, Tick}; use super::active_camera; +use super::camera::auto_orient_2d; use crate::EvalCtx; fn setup() -> (Project, ItemId, Comp) { @@ -78,6 +79,69 @@ fn orient_along_path_keeps_the_last_heading_at_and_after_the_end() { } } +/// #1650: the same end-of-path flip when Position has no keyframes of its own and is driven by +/// an expression (e.g. `pointOnPath()` fed by a keyframed slider on another layer): once the +/// motion stops there are no Position keys to fall back on, and the heading snapped to 0 degrees. +#[test] +fn orient_along_path_keeps_the_last_heading_when_position_is_an_expression() { + /// Stand-in for an expression: holds, moves 100 px left over 0.5..1 s, then holds again (like + /// a slider going from 0% to 100%). + struct LeftThenHold; + impl crate::ExprHost for LeftThenHold { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let s = ((ctx.time.seconds() - 0.5) * 2.0).clamp(0.0, 1.0); + Ok(Value::Vec3([150.0 - 100.0 * s, 50.0, 0.0])) + } + } + for three_d in [false, true] { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, three_d, [150.0, 50.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + let pos = l.props.prop_mut("transform/position").unwrap(); + assert!(pos.keys.is_empty(), "the fixture must have no Position keyframes"); + pos.expr = Some(effectcraft_project::Expression { text: "left then hold".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for secs in [0.2, 0.75, 1.0, 1.0 + 1.0 / 30.0, 1.2, 1.9] { + let ctx = EvalCtx { expr: Some(&LeftThenHold), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(secs)) }; + let x_axis = ctx.world_matrix(&c.layers[0]).apply_vec(vec3(1.0, 0.0, 0.0)); + // Moving left (-x): the layer's x axis points left before the motion starts and after it stops. + assert!((x_axis.x + 1.0).abs() < 1e-6 && x_axis.y.abs() < 1e-6 && x_axis.z.abs() < 1e-6, "3d={three_d} t={secs}: {x_axis:?}"); + } + } +} + +/// #1650 on a curved path: the heading kept once an expression stops moving is the one at the end +/// of the motion, the same on every later frame, not whatever earlier point a coarse search lands on. +#[test] +fn orient_along_a_curved_expression_path_keeps_the_end_heading() { + /// Quarter circle over 0..1 s: heading 0 degrees at the start, 90 degrees (down) at the end. + struct Arc; + impl crate::ExprHost for Arc { + fn eval(&self, ctx: &EvalCtx, _: &Layer, prop: &effectcraft_project::Property, value: &Value) -> Result { + if prop.match_id != "position" { + return Ok(value.clone()); + } + let a = ctx.time.seconds().clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2; + Ok(Value::Vec3([100.0 * a.sin(), 100.0 - 100.0 * a.cos(), 0.0])) + } + } + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, false, [0.0, 0.0, 0.0]); + l.auto_orient = AutoOrient::AlongPath; + l.props.prop_mut("transform/position").unwrap().expr = Some(effectcraft_project::Expression { text: "arc".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + for frame in [31, 33, 37, 45, 59] { + let ctx = EvalCtx { expr: Some(&Arc), ..EvalCtx::new(&p, cid, c, Tick::from_seconds_f64(frame as f64 / 30.0)) }; + let deg = auto_orient_2d(&ctx, &c.layers[0]); + assert!((deg - 90.0).abs() < 0.5, "frame {frame}: {deg}"); + } +} + #[test] fn layers_parented_to_a_camera_follow_its_rotation() { let (mut p, cid, comp) = setup(); From 48ad0d1c56002b584bfff24bdc367d4ddcf68266 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Sat, 17 Oct 2026 00:18:04 +0900 Subject: [PATCH 0095/1386] i18n: the aspect-ratio lock in Composition Settings asks the catalog The row has been in every catalog; `format!` built the label in English instead. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/panels/comp_settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..5831c39dc 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -320,7 +320,7 @@ pub fn show( let (ow, oh) = (d.width, d.height); let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); - let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); + let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]); let r = ui.checkbox(&mut d.lock_aspect, lbl); auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio"); if d.lock_aspect && ow != d.width && ow > 0 { From 0b3d4631f2750516ae3e9897d2a7a08575e7f2b7 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Sat, 17 Oct 2026 00:18:04 +0900 Subject: [PATCH 0096/1386] i18n: the recovery dialog's relative times ask the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "N minutes ago" and "N hours ago" have rows in all seven catalogs; a Japanese interface read `保存日時:… {m} minutes ago`. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/panels/settings.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..7b54a087e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -779,9 +779,9 @@ pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if m < 1 { "less than a minute ago".to_string() } else if m < 120 { - format!("{m} minutes ago") + tr_args("{} minutes ago", &[&m]) } else { - format!("{} hours ago", m / 60) + tr_args("{} hours ago", &[&(m / 60)]) } }); ui.label(tr_args("Latest auto-save: {}", &[&a])); From b4c1569680fd3b831c0e9e86995fb6fb22bdb3e5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 17:41:51 +0200 Subject: [PATCH 0097/1386] Merge main into #1339; name the new Properties label control The merge of main brought a double-click label hit area on the Properties transform rows that had no accessible name; give it one so the audit ratchet holds. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/effects_presets.rs | 4 ++-- crates/ui-egui/src/panels/properties.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 17bfda59c..d4e7ab909 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -346,14 +346,14 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: impl FnOnce() -> Apply, + on_apply: impl Fn() -> Apply, apply: &mut Option, ) { let t = app.tokens; let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); let id = egui::Id::new(("fxpreset", auto_id)); let tab = nav.tab_stop(id); - let selected = nav.push(id, *y, 3, None, Some(on_apply.clone())); + let selected = nav.push(id, *y, 3, None, Some(on_apply())); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 84d8d4085..213ef01a8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -246,6 +246,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let vx = r.min.x + (r.width() * 0.5).max(130.0); let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y)); let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click()); + crate::a11y::role_name(&resp, egui::accesskit::Role::Button, &pr.name); app.auto.add(&format!("{base}.label"), label, &pr.name); if resp.double_clicked() { app.ui.timeline.search.clear(); From 6c3a08e7802c7a71b496aaf75d8e9f23f34f0bf2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Sat, 17 Oct 2026 02:38:31 +0530 Subject: [PATCH 0098/1386] Preview: an edit keeps frames only outside Solo changes and the shutter (#1641 review) Turning Solo on or off hides or shows every other layer at every frame, so it now recaches the whole comp. The kept span is widened by the comp's shutter (phase and angle), since a frame's motion blur samples cameras, lights and transforms up to three frames away. Comp::any_solo is shared with the renderer so both read Solo the same way. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/lib.rs | 25 +++++++--- crates/render/src/lib.rs | 2 +- crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/frames.rs | 64 ++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index d80939f2b..2d01a3132 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -898,11 +898,11 @@ impl Comp { && *essential == o.essential } - /// The comp-time span (`in_point..out_point` of every layer changed, added or removed) - /// outside which `self` draws the same pixels as `o`, so cached frames there stay valid - /// (#1641). `None` when the edit can reach any frame: a comp setting, a layer reorder, an - /// expression (it reads anything), or a changed layer another layer parents to, mattes - /// with or picks in an effect. + /// The comp-time span (`in_point..out_point` of every layer changed, added or removed, + /// widened by the shutter) outside which `self` draws the same pixels as `o`, so cached + /// frames there stay valid (#1641). `None` when the edit can reach any frame: a comp setting, + /// a layer reorder, Solo turning on or off, an expression (it reads anything), or a changed + /// layer another layer parents to, mattes with or picks in an effect. pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> { fn reads(g: &PropGroup, changed: &[LayerId]) -> bool { g.children.iter().any(|c| match c { @@ -915,7 +915,8 @@ impl Comp { Node::Group(g) => reads(g, changed), }) } - if !self.same_settings(o) { + // Solo hides every other layer at every frame. + if !self.same_settings(o) || self.any_solo() != o.any_solo() { return None; } let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::>(); @@ -939,7 +940,17 @@ impl Comp { if self.layers.iter().chain(&o.layers).any(reaches) { return None; } - span + // A frame at `t` samples cameras, lights and transforms over its shutter, + // `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself. + let (a, p) = (self.shutter_angle / 360.0, self.shutter_phase / 360.0); + let fd = self.frame_duration().seconds(); + let (before, after) = (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd)); + span.map(|(s, e)| (s - before, e + after)) + } + + /// Some layer is soloed: only the soloed layers draw. + pub fn any_solo(&self) -> bool { + self.layers.iter().any(|l| l.switches.solo && l.source.is_av()) } /// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02db..c2c2f71b1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2100,7 +2100,7 @@ impl<'a> Renderer<'a> { /// applied). pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> { let (comp, t) = (ctx.comp, ctx.time); - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = comp.any_solo(); comp.layers .iter() .rev() diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 345179331..2e6ffcb05 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = nctx.comp.any_solo(); for l in nctx.comp.layers.iter().rev() { if !l.is_active_at(t) || l.is_light() diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 831ab3ec4..ec7f76feb 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -497,7 +497,7 @@ fn collapsed_items<'a>( let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); + let any_solo = nctx.comp.any_solo(); let nested: Vec<&'a Layer> = nctx .comp .layers diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 039955fc5..57d4c99d3 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -747,8 +747,8 @@ impl Frames { { return (*base_id, Some(base.clone())); } - // Only layers of this comp changed: its frames outside their span (a frame either side - // for motion blur) keep drawing the same pixels, so a cut or trim recaches only the + // Only layers of this comp changed: its frames outside their span (and a frame either + // side for rounding) keep drawing the same pixels, so a cut or trim recaches only the // layers it touched (#1641). if let Some((base_id, base)) = bases.get(&comp.0) && let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp)) @@ -2058,6 +2058,61 @@ mod tests { assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo"); } + /// Every frame an edit keeps renders as a fresh render of the edited project does: Solo + /// hides the other layers at every frame, and a camera reaches the frames whose shutter + /// samples it (#1641 review). + #[test] + fn the_frames_an_edit_keeps_match_a_fresh_render() { + use effectcraft_engine::Session; + use effectcraft_engine::render::NoFootage; + use serde_json::json; + // Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders. + fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec { + let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap(); + let f = Frames::default(); + let before = f.content_of(&s.project, s.revision, comp); + (0..n).for_each(|i| insert(&f, key(before, i, 0))); + let old = s.project.clone(); + s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap(); + let after = f.content_of(&s.project, s.revision, comp); + let kept: Vec = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect(); + let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i)); + for &i in &kept { + assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now"); + } + kept + } + let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap(); + let mut s = Session::default(); + + // Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again. + let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp")); + s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(); + let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap(); + let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}}); + assert_eq!(kept(&mut s, comp, solo(true)), Vec::::new()); + assert_eq!(kept(&mut s, comp, solo(false)), Vec::::new()); + + // A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame + // samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it. + let comp = ItemId(id( + s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360})) + .unwrap(), + "comp", + )); + let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer"); + for sw in ["threeD", "motionBlur"] { + s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap(); + } + let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer"); + s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap(); + let moved = + kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}})); + assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}"); + assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}"); + } + #[test] fn short_solid_additions_and_removals_keep_unaffected_frames() { use effectcraft_engine::Session; @@ -2071,11 +2126,12 @@ mod tests { (0..600).for_each(|n| insert(&f, key(content, n, 0))); }; let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0)); - let kept: Vec = (0..=58).chain(92..600).collect(); + // 2–3 s, a quarter frame of the default shutter and a frame of rounding either side. + let kept: Vec = (0..=57).chain(92..600).collect(); cache_all(&s); let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap(); - assert_eq!(cached(&s), kept, "adding a source invalidates only frames 59..=91"); + assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91"); cache_all(&s); s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.undo", json!({})).unwrap(); From 0fbe76b2bfbb6ab6d919ace5446c3d51ea5a981c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Sat, 17 Oct 2026 01:48:48 +0200 Subject: [PATCH 0099/1386] Cryptomatte: CryptoAux enum instead of a positional bool; GPU test for every display mode (#473) crypto_aux_image_with takes CryptoAux::{ColorsAndMatte, MatteOnly}, and CryptoAux::for_display holds the rule for which displays read the ID colours. A new GPU test checks every display x Matte Only against the CPU, so the GPU fails the test if the shader starts reading RGB in a mode that skips it (checked by mutation: forcing MatteOnly fails display 0). --- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_depth.rs | 5 +++-- crates/gpu/src/tests_fx_depth.rs | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4c1ea60b7..5a083392a 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -110,7 +110,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, CryptoAux, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index a74ff717b..1bb0e49e1 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -280,8 +280,9 @@ fn cryptomatte(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: &AuxChannels) -> Option // cores with the rank planes resolved once. // The ID colours are only read by the Colors display; Matte, Preview and Matte Only need the // matte alone. - let colors = pr.e("display") == 0 && !pr.b("matteOnly"); - let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, colors); + // `tests_fx_depth::cryptomatte_every_display_matches_the_cpu` guards this against the shader. + let what = effectcraft_effects::CryptoAux::for_display(pr.e("display"), pr.b("matteOnly")); + let img = effectcraft_effects::crypto_aux_image_with(a, &layer, &sel, what); let tex = e.g.upload_image(&img)?; let plane = lookup(e, &b, a, &tex, [0.5, 0.5], [0.0; 4]); let mut p = op(7); diff --git a/crates/gpu/src/tests_fx_depth.rs b/crates/gpu/src/tests_fx_depth.rs index b84b4849d..8a6665655 100644 --- a/crates/gpu/src/tests_fx_depth.rs +++ b/crates/gpu/src/tests_fx_depth.rs @@ -251,6 +251,18 @@ fn ids_and_cryptomatte() { case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]); } +/// The GPU skips the ID colours for displays that do not read them +/// ([`effectcraft_effects::CryptoAux::for_display`]): every display × Matte Only must still match +/// the CPU effect, so a shader that starts reading RGB in another mode fails here. +#[test] +fn cryptomatte_every_display_matches_the_cpu() { + for display in 0..3 { + for matte_only in [false, true] { + case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into())), ("display", e(display)), ("matteOnly", Value::Bool(matte_only))]); + } + } +} + #[test] fn extractor() { case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]); From be9c9077cd62232f3a6ecac2a6c2d309cfb68545 Mon Sep 17 00:00:00 2001 From: p01ntov <278898977+p01ntov@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:56:35 +0300 Subject: [PATCH 0100/1386] G2: tessellate viewer path overlays in screen space --- crates/ui-egui/src/panels/viewer_overlays.rs | 152 ++++++++++++++++++- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 9117103d5..d0875941e 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -679,6 +714,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { From ee17172fb688ffe1abb07de2db9c7d806f4e5198 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:50:04 -0400 Subject: [PATCH 0101/1386] Media: update FilmCraft to 09aaf9b, which reads AVI (#1701) Moves the pin past FilmCraft's AVI / OpenDML reader (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3). No EffectCraft API changes. --- Cargo.lock | 144 +++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 32 ++++++------ 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06da2cebd..1dea8187a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2043,8 +2043,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2052,16 +2052,16 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-apv" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2070,33 +2070,39 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" + [[package]] name = "filmcraft-bitstream" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-cfb" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-codecs" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-aac", "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -2106,6 +2112,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2113,9 +2120,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2123,16 +2132,25 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2159,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2152,16 +2170,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2170,8 +2188,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2180,8 +2198,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2190,8 +2208,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2207,8 +2225,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2216,15 +2234,16 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-media" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", @@ -2233,10 +2252,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2244,31 +2272,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-ogg" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-opus" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-project" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2281,8 +2309,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2291,16 +2319,24 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-vp8" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 7b9983788..8810e3c16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,26 +72,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned # rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } From 01ad187b97888efedd588130f12d20887e7a610b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:04 -0400 Subject: [PATCH 0102/1386] Media: drop the AIFF workaround dependency (#1701) FilmCraft's codecs now turn on symphonia's AIFF reader themselves. --- Cargo.lock | 1 - Cargo.toml | 3 --- crates/media/Cargo.toml | 2 -- 3 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dea8187a..8fc3183ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1455,7 +1455,6 @@ dependencies = [ "log", "rayon", "serde_json", - "symphonia", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 8810e3c16..b1c6caaf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,9 +92,6 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9b filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } -# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned -# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. -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"] } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index bb47dccb0..a3ff624b4 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,8 +24,6 @@ filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } -# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. -symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } From ca724a3d84c8fcd1724b3de724ed4c5c4507ee53 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:05 -0400 Subject: [PATCH 0103/1386] Media: downmix 7.1 AAC in the order FilmCraft now delivers it (#1701) FilmCraft reorders AAC channel configuration 12 (7.1) to WAVE/SMPTE order, as it does 3 to 6, so 7.1 AAC's left channel was mixed as the centre. The downmix oracle test gains 6.1 (a program config element, left in AAC's order) and 7.1 AAC fixtures. --- crates/media/src/downmix.rs | 16 +++++++++------- crates/media/tests/downmix.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index e8a9237e0..1ad9721ba 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -65,8 +65,10 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { } /// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 -/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, -/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +/// channels) and 12 (7.1, 8 channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs, +/// L R C LFE Lb Rb Ls Rs), everything else (mono, stereo, program-config layouts such as the 6.1 +/// ffmpeg writes) in AAC's element order. Configuration 11 (6.1) and 7 (7.1 with front wides) +/// can't be told from those by the channel count and are read in the order of the commoner one. pub fn decoded_aac_gains(count: usize) -> Vec { match count { // C L R Cs, reordered to L R C Cs: the same gains, permuted. @@ -74,7 +76,7 @@ pub fn decoded_aac_gains(count: usize) -> Vec { let g = stereo_gains(4, true); [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() } - 3 | 5 | 6 => stereo_gains(count, false), + 3 | 5 | 6 | 8 => stereo_gains(count, false), _ => stereo_gains(count, true), } } @@ -118,9 +120,9 @@ mod tests { } #[test] - fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { - // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. - for n in [3, 5, 6] { + fn decoded_aac_is_in_wav_order_for_three_to_six_and_eight_channels() { + // FilmCraft hands AAC 3.0 to 5.1 and 7.1 over in the order of the plain layouts. + for n in [3, 5, 6, 8] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); } let g = decoded_aac_gains(4); @@ -128,7 +130,7 @@ mod tests { assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); - for n in [1, 2, 7, 8, 9] { + for n in [1, 2, 7, 9] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); } } diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/downmix.rs index e0e7d2252..e2c95c251 100644 --- a/crates/media/tests/downmix.rs +++ b/crates/media/tests/downmix.rs @@ -94,8 +94,18 @@ fn cases() -> Vec { let (n71, g71) = seven_one(); let k5 = 1.0 / (1.0 + 2.0 * K); let kq = 1.0 / (1.0 + K); + let k61 = 1.0 / (1.5 + 2.0 * K); vec![ Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { + file: "dm61.m4a", + layout: "6.1", + names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"], + codec: &["-c:a", "aac", "-b:a", "448k"], + gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]], + tol: 0.15, + }, + Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 }, Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, From bed49c4959787c41e4fd5986db9683f2debe8af5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 0104/1386] Media: import AVI (#1701) One list of movie extensions, in effectcraft-project, with AVI added: FilmCraft reads AVI 1.0 / OpenDML. Tests against ffmpeg fixtures: Motion JPEG with PCM audio, an unsupported codec (imports and says why it has no picture), a file that isn't an AVI (an import error), and a damaged one (a truncated recording imports as far as it goes; a garbled packet loses its own frame only). --- crates/media/src/lib.rs | 3 +- crates/media/tests/footage.rs | 135 ++++++++++++++++++++++++++++++++-- crates/project/src/lib.rs | 5 ++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..1b40fcfac 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -63,8 +63,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; -/// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..8dd4db672 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -168,9 +168,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -472,12 +477,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case. #[test] -fn mxf_is_importable() { - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); - assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -523,6 +528,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame_in(&p, n, "bt601")); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..70a85a718 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1089,6 +1089,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] From e26b4af5c07fe8dd29139c71fe5e0a4848d26a43 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 0105/1386] Media Browser: list movies by the importer's list (#1701) --- crates/engine/src/media_browser.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b394ad937..775e3db98 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -420,6 +420,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); From 93a9295125e5487c0b9e99cb904cd07bb340901b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 0106/1386] File dialogs: offer every movie type the importer reads (#1701) Import and Set Proxy build their filters from the shared movie list, so they offer AVI; the web build's file picker gets the same list. --- crates/ui-egui/src/menus.rs | 19 ++++++++++++++----- crates/ui-egui/tests/ui_menus.rs | 20 ++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f6..eda7ccf7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1337,6 +1337,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("paths", Ask::Import), @@ -1366,7 +1368,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1377,19 +1379,22 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option>(); // A host whose modal dialogs would run a nested event loop inside this frame (macOS: a // drag over the window then re-entered winit's event handler and aborted, #511) shows the // dialog without blocking; the command continues when the answer arrives. if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -1409,7 +1414,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1419,6 +1424,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..ce6b97800 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -205,10 +205,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { std::fs::remove_dir_all(dir).unwrap(); } -/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and -/// Avid), which the Media Browser lists and the importer reads. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -219,11 +219,15 @@ fn import_dialog_offers_mxf() { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); - assert!(result.is_null(), "cancelled: nothing imported"); - let offered = offered.borrow(); - assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); - assert!(offered.iter().any(|e| e == "mov")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews From 84d0f567656a0ba1b68da5ccbf804c64d2cdc74f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 0107/1386] Docs: AVI import (#1701) --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..f6e8913c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | none | none | Media Browser lists it | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | diff --git a/docs/gaps.md b/docs/gaps.md index a20e779cd..bda5eba76 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest. | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eac744b6f..74111b27d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,7 +114,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | From c7715a3f2d43d79e5d419c29aba736444443dd4d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 0108/1386] Footage: a proxy in use that can't be decoded says why (#1701) The picture comes from an item's proxy while Use Proxy is on, but the Project panel, the Viewer, project.summary and footage.info asked only about the item's own file: a proxy that couldn't be decoded (an AVI with an unsupported codec, say) left the layer empty with no reason given. They now ask FootageSource::item_problem, which gives the proxy's reason, said to be the proxy's, for footage and compositions alike. --- crates/engine/src/commands/footage_panel.rs | 5 +-- crates/engine/src/commands/query.rs | 8 ++-- crates/engine/src/tests_panels.rs | 44 +++++++++++++++++++ crates/render/src/lib.rs | 12 +++++ crates/ui-egui/src/panels/project.rs | 9 ++-- crates/ui-egui/src/panels/viewer.rs | 11 ++--- crates/ui-egui/tests/ui_unreadable_footage.rs | 39 +++++++++++----- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 1bd0d2236..7336d0c08 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -207,10 +207,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02db..b5aada454 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -130,6 +130,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 76309a5d0..ab5936765 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -678,12 +678,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 51bab7fda..a50c30e53 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1155,13 +1155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. let mut notices = 0; for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; + let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { + continue; + }; + let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; if notices >= 3 { break; } diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index 9b48b9531..b1b12d6d2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,7 +39,11 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); @@ -48,7 +53,7 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +62,19 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} From 0a57bb9fc21a909adb822c017f22a0fd497a840d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 0109/1386] Docs: the AVI variants that don't import yet (#1701) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f6e8913c4..8ca81f715 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | From f64cd5ce381e3d7e1863eef2ce3599f3a6e262a0 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sat, 17 Oct 2026 20:44:28 +0000 Subject: [PATCH 0110/1386] 1010: preserve easing while baking parented Orientation --- crates/engine/src/commands/layer.rs | 10 +++++++++- crates/engine/src/tests_rig3d.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 123a81471..e26f0279b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -818,7 +818,15 @@ fn bake_between( let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); - if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { return; } let em = nearest_euler(euler_zyx(exact.0), blend); diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d867337ac..595d3d7c5 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -325,6 +325,27 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { From aff4b082d7157e426c81c6844df739d9b9f09cbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:23:58 -0700 Subject: [PATCH 0111/1386] Fix #341: on macOS, a quit from a visible window goes through `terminate:` Quit EffectCraft and Finder's Quit send `ViewportCommand::Close`, and the close button sends the window's close request. In each case eframe drops the window while AppKit's run loop is still running. On a Mac with a Touch Bar, AppKit's Touch Bar code then raises an uncaught Objective-C exception (`-[_NSTouchBarFinderObservation invalidate]`) and the app crashes (storytold/craft-libs#3). After the unsaved-changes check, `close_or_quit` calls `craft_quit::on_close_request` from craft-libs, pinned to the rev that craft-docpath already uses. When nothing holds the window open, it cancels the close and quits through `-[NSApplication terminate:]`, which still runs `on_exit` (window position, workspace, crash-recovery cleanup). The prompt for a modified project still holds the window. The window close after a lost graphics device takes the same route. On other platforms it does nothing. pdfcraft adds craft-quit the same way (storytold/pdfcraft#1533). A quit while the window is minimized or hidden still closes the window as before: eframe does not run `ui` then, and the unsaved-changes check and the new call both run from `ui`. `terminate:` ends the process before `eframe::run_native` returns, so the log line for the closing window moves from `main` into `on_exit`. --- Cargo.lock | 12 +++++++ Cargo.toml | 1 + apps/effectcraft/src/main.rs | 14 ++++---- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/lib.rs | 5 ++- crates/ui-egui/src/panels/unsaved.rs | 51 ++++++++++++++++++++++++++++ crates/ui-egui/tests/ui_gpu.rs | 7 ++++ 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2f0c927d..8c354978a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,6 +982,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1651,6 +1662,7 @@ dependencies = [ name = "effectcraft-ui-egui" version = "0.11.0" dependencies = [ + "craft-quit", "craft-ui", "effectcraft-color", "effectcraft-engine", diff --git a/Cargo.toml b/Cargo.toml index 5061bdfcd..60e97e13c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ 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-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal effectcraft-time = { path = "crates/time" } effectcraft-geom = { path = "crates/geom" } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2f99bd5e0..4a8d5332a 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -327,14 +327,11 @@ fn main() -> eframe::Result { })) }), ); - 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 } @@ -429,6 +426,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(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index be02fef5e..ea356b871 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-quit = { workspace = true, features = ["egui"] } effectcraft-time = { workspace = true } effectcraft-geom = { workspace = true } effectcraft-color = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..938e828c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1657,7 +1657,7 @@ impl EffectcraftApp { self.run_script_tasks(&ctx); self.apply_prefs(&ctx); self.handle_events(&ctx); - panels::unsaved::on_close_requested(self, &ctx); + panels::unsaved::close_or_quit(self, &ctx); let title = panels::unsaved::window_title(self); if title != self.window_title { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); @@ -1903,6 +1903,9 @@ impl eframe::App for EffectcraftApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { if self.gpu_failure_applied == Some(true) { self.close_after_device_loss(ui.ctx()); + // On macOS the app quits through `-[NSApplication terminate:]` instead of closing the + // window while AppKit's run loop is running (#341). + craft_quit::on_close_request(ui.ctx()); return; } self.init_gpu(frame); diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index 05f719fa9..e509f889e 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -58,6 +58,15 @@ pub fn on_close_requested(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// [`on_close_requested`], then on macOS a close that nothing held open quits through +/// `-[NSApplication terminate:]` instead: closing the window while AppKit's run loop is running +/// crashes on a Mac with a Touch Bar (#341). `on_exit` still runs. Returns whether that quit was +/// scheduled; other platforms close the window as before. +pub fn close_or_quit(app: &mut EffectcraftApp, ctx: &egui::Context) -> bool { + on_close_requested(app, ctx); + craft_quit::on_close_request(ctx) +} + /// The project's display name: its file name, or `Untitled Project.ecproj`. pub fn project_name(app: &EffectcraftApp) -> String { app.session @@ -207,6 +216,23 @@ mod tests { out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose)) } + /// One frame of the dialogs with the window's close button pressed, through [`close_or_quit`] + /// as the app runs it; returns whether the close was canceled and whether the platform quit + /// was scheduled. + fn close_frame(app: &mut EffectcraftApp, ctx: &egui::Context) -> (bool, bool) { + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + let mut quits = false; + let mut out = ctx.run_ui(raw, |ui| { + app.auto.begin_frame(); + quits = close_or_quit(app, ui.ctx()); + crate::panels::dialogs::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + let canceled = out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose)); + (canceled, quits) + } + fn modified(app: &mut EffectcraftApp) { app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64})).unwrap(); assert!(app.session.is_dirty()); @@ -340,4 +366,29 @@ mod tests { assert!(!app.session.is_dirty()); assert!(!frame(&mut app, &ctx, true)); } + + /// Closing the window while AppKit's run loop is running crashes on a Mac with a Touch Bar + /// (#341). On macOS the close is canceled and the app quits through + /// `-[NSApplication terminate:]`. Other platforms close the window. + #[test] + fn closing_a_saved_project_quits_through_the_platform_on_macos() { + let (mut app, ctx) = app(); + let macos = cfg!(target_os = "macos"); + assert_eq!(close_frame(&mut app, &ctx), (macos, macos)); + assert!(app.dialog.is_none(), "nothing to ask"); + } + + #[test] + fn closing_a_modified_project_asks_before_the_platform_quit() { + let (mut app, ctx) = app(); + modified(&mut app); + assert_eq!(close_frame(&mut app, &ctx), (true, false), "the prompt holds the window and nothing quits"); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + answer_with(&mut app, &ctx, Answer::Cancel); + assert!(app.session.is_dirty() && !app.dialog_state.unsaved.quitting); + assert_eq!(close_frame(&mut app, &ctx), (true, false), "asked again after Cancel"); + answer_with(&mut app, &ctx, Answer::Discard); + let macos = cfg!(target_os = "macos"); + assert_eq!(close_frame(&mut app, &ctx), (macos, macos), "Don't Save quits"); + } } diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 906ac0cd1..355c35bf5 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -115,5 +115,12 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() { let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); assert!(std::path::Path::new(&saved).is_file(), "{saved}"); assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy"); + // The close arrives: on macOS the app quits through `-[NSApplication terminate:]` instead of + // closing the window while AppKit's run loop is running (#341). + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + h.step(); + let kept = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(kept, cfg!(target_os = "macos"), "the platform quit closes the window on macOS"); + assert_eq!(told.borrow().len(), 1); let _ = std::fs::remove_dir_all(&dir); } From 09dad1a51f14a8e80d986e2fb0ee4521986c2a05 Mon Sep 17 00:00:00 2001 From: Egon5000 <210198019+elZanco@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:49:37 +0200 Subject: [PATCH 0112/1386] Timeline: a shape group's inner Contents draws no row, as After Effects elides it --- crates/ui-egui/src/panels/timeline.rs | 24 ++++++++++++++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 23 +++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index aff4ef2c8..a51644a37 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -756,6 +756,8 @@ fn search_rows(l: &Layer, q: &str) -> Vec { fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec) { for c in &g.children { match c { + // A shape group's inner Contents is elided (as in AE): no row, items at the same depth. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => all(l, sg, depth, out), Node::Group(sg) => { out.push(group_row(l, sg, depth, g)); all(l, sg, depth + 1, out); @@ -768,6 +770,8 @@ fn search_rows(l: &Layer, q: &str) -> Vec { fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec) { for c in &g.children { match c { + // A shape group's inner Contents is elided (as in AE): search through it, no row. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => rec(l, sg, depth, q, out), Node::Group(sg) if depth > 1 || group_visible(sg, l) => { // An effect group also matches the name the row draws, so a search finds // `高斯模糊` in a Chinese interface and `Gaussian Blur` in an English one. @@ -1178,6 +1182,11 @@ fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => { push_group(rows, l, sg, depth, open); } + // A shape group's inner Contents (AE's elided `ADBE Vectors Group`): the group's items + // (path, fill/stroke, Transform) list directly under it, with no "Contents" row. + Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => { + push_group(rows, l, sg, depth, open); + } Node::Group(sg) => { let o = open.contains(&sg.uid); let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled); @@ -4370,6 +4379,21 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + /// A shape group's inner Contents (AE's elided `ADBE Vectors Group`) draws no row: a search + /// finds the group's items directly, and the only "Contents" row is the layer's. + #[test] + fn shape_group_inner_contents_is_elided() { + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.search = "rect".into(); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == ">Rectangle 1"), "the shape group matches: {rows:?}"); + assert!(rows.iter().any(|r| r.trim() == ">Rectangle Path 1"), "its path lists directly under it: {rows:?}"); + assert_eq!(rows.iter().filter(|r| r.trim() == ">Contents").count(), 1, "only the layer's Contents: {rows:?}"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 3993a6f99..bc42b4461 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -75,6 +75,29 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +/// A shape group's inner Contents is elided in After Effects: opening the group lists its items +/// (here the path) directly, with no "Contents" row between them. +#[test] +fn shape_group_contents_has_no_inner_contents_row() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let contents = layer.props.sub("contents").unwrap().clone(); + let group = contents.groups().next().unwrap().clone(); + let inner = group.sub("contents").unwrap().clone(); + let item = inner.groups().next().unwrap().clone(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.insert(contents.uid); + h.state_mut().ui.timeline.open_groups.insert(group.uid); + h.run_steps(2); + let name = |uid: u64| format!("timeline.group.{uid}.name"); + assert!(h.state().auto.find(&name(contents.uid)).is_some(), "the layer's Contents row"); + assert!(h.state().auto.find(&name(group.uid)).is_some(), "the shape group row"); + assert!(h.state().auto.find(&name(inner.uid)).is_none(), "no inner Contents row"); + assert!(h.state().auto.find(&name(item.uid)).is_some(), "the path lists directly under the group"); +} + #[test] fn classic_3d_shape_layers_offer_change_renderer() { let mut s = session(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5173e7691..634422605 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | @@ -44,6 +44,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | A shape group's inner Contents (After Effects' elided Vectors group) draws no row: the group's path, fill/stroke and Transform list directly under the group | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 46040928c8892f6d4fc1743869c090d229ab6dd9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 21:56:54 -0400 Subject: [PATCH 0113/1386] #1615: cap CPU frames in flight during export (EFFECTCRAFT_CPU_INFLIGHT) CPU renders kept one frame per thread in flight with no cap, so heavy comps could be OOM-killed. EFFECTCRAFT_CPU_INFLIGHT=N renders at most N frames at once through the existing capped path; unset keeps today's behaviour. With a GPU cap too, the smaller cap holds for the whole batch. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 68 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 8dcee03e4..bd80a2a6c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -39,6 +39,40 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders +/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working +/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI +/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames. +pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT"; + +/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or +/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a +/// number or too large. +pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { + let v = var?; + if v.trim().is_empty() { + return None; + } + match v.trim().parse::() { + Ok(n) if n >= 1 => Some(n), + _ => { + log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped"); + None + } + } +} + +/// The frames-in-flight cap of a batch, and whether it lapses once the GPU stops for good: +/// a GPU cap alone lapses (the CPU fallback renders in parallel, #1511), a CPU cap never does, +/// and with both the smaller one holds for the whole batch. +pub(crate) fn inflight_cap(gpu: Option, cpu: Option) -> (Option, bool) { + match (gpu, cpu) { + (Some(g), None) => (Some(g), true), + (Some(g), Some(c)) => (Some(g.min(c)), false), + (None, c) => (c, false), + } +} + /// `render` then `encode` over the frames `ks`, results in `ks` order. /// /// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as @@ -208,10 +242,11 @@ impl<'a> Cx<'a> { } return out; } - // The cap is for the GPU's memory: once it has stopped for good, frames render on the - // CPU in parallel. - let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) + // A GPU cap alone is for the GPU's memory: once it has stopped for good, frames render on + // the CPU in parallel. A CPU cap (#1615) holds throughout. + let (cap, lapses) = inflight_cap(self.gpu_cap(), cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref())); + let live = || !lapses || self.job.accel.is_none_or(|a| a.available()); + map_frames_while(ks, cap, live, |k| self.frame(comp, k), f) } /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every @@ -646,6 +681,31 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn cpu_inflight_is_off_unless_a_positive_number() { + assert_eq!(cpu_inflight(None), None); + assert_eq!(cpu_inflight(Some("2")), Some(2)); + assert_eq!(cpu_inflight(Some(" 4 ")), Some(4)); + for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] { + assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn cpu_cap_holds_and_combines_with_the_gpu_cap() { + // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. + assert_eq!(inflight_cap(None, None), (None, false)); + assert_eq!(inflight_cap(None, Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), None), (Some(1), true)); + assert_eq!(inflight_cap(Some(3), Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), Some(4)), (Some(1), false)); + // A CPU cap of 2 on 8 threads renders at most 2 frames at once. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (out, max) = pool.install(|| run_capped(16, inflight_cap(None, Some(2)).0)); + assert_eq!(out.len(), 16); + assert!(max <= 2, "{max} frames in flight"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); From 5939735c0b79cad7ec80a386272f1417d8c42cfd Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 02:25:40 +0000 Subject: [PATCH 0114/1386] fix(i18n): follow browser language preferences (#1700) --- crates/ui-egui/src/i18n.rs | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 81343f9f8..2ed342a52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,36 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. In the browser, use its ordered language preferences (#1700). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + { + static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + LANGUAGE.get_or_init(|| { + let Some(window) = eframe::web_sys::window() else { return "en" }; + let navigator = window.navigator(); + let locales: Vec = navigator.languages().iter().filter_map(|v| v.as_string()).collect(); + browser_language(locales.iter().map(String::as_str), navigator.language().as_deref()) + }) + } +} + +/// Pick the first supported browser preference, including an explicit English preference. +/// Older browsers may only supply `language`; unknown or absent languages use English. +#[cfg(any(target_arch = "wasm32", test))] +fn browser_language<'a>(mut locales: impl Iterator, fallback: Option<&str>) -> &'static str { + locales + .find_map(|locale| { + let language = supported(Some(locale)); + let english = locale.split(['-', '_']).next().is_some_and(|primary| primary.eq_ignore_ascii_case("en")); + (language != "en" || english).then_some(language) + }) + .unwrap_or_else(|| supported(fallback)) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -90,6 +110,9 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ru" => "ru", + "fr" => "fr", + "de" => "de", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -6887,6 +6910,24 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + #[test] + fn browser_preferences_choose_a_supported_language_in_order() { + for (locales, fallback, expected) in [ + (vec!["es-ES"], None, "es"), + (vec!["ja-JP", "es-ES"], None, "ja"), + (vec!["xx-XX", "es-MX"], None, "es"), + (vec!["en-US", "es-ES"], None, "en"), + (vec!["zh-TW"], None, "zh-hant"), + (vec!["de-DE", "es-ES"], None, "de"), + (vec![], Some("ja-JP"), "ja"), + (vec!["xx-XX"], Some("es-ES"), "es"), + (vec![], None, "en"), + (vec!["xx-XX"], Some("xx-XX"), "en"), + ] { + assert_eq!(browser_language(locales.into_iter(), fallback), expected); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -6918,7 +6959,9 @@ mod tests { (Some("CS_cz.UTF-8"), "cs"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), - (Some("de-DE"), "en"), + (Some("de-DE"), "de"), + (Some("fr-CA"), "fr"), + (Some("ru-RU"), "ru"), (None, "en"), ] { assert_eq!(supported(locale), language, "{locale:?}"); From 71a9ab085228a8f2df6f1910b65c1d358a6030fe Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sun, 18 Oct 2026 14:06:19 +0900 Subject: [PATCH 0115/1386] G9: translate the new panel labels into Japanese --- crates/ui-egui/src/i18n/ui.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 206111892..86a286123 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -636,6 +636,21 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Apply", "適用"), + ("Analyze", "分析"), + ("Create Camera", "カメラを作成"), + ("Add Point", "ポイントを追加"), + ("Remove Point", "ポイントを削除"), + ("Render", "レンダリング"), + ("Save Current as Template…", "テンプレートとして保存…"), + ("New Composition From Footage", "フッテージから新規コンポジション"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Search project", "プロジェクトを検索"), + ("Search", "検索"), + ("(no composition)", "(コンポジションなし)"), + ("per second", "毎秒"), + ("Waveform", "波形"), + ("steps", "ステップ"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ From 2de015fe9f8de5b62ed2c9e1bd9b5cd044aad585 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:52:04 +0000 Subject: [PATCH 0116/1386] Fix HexTile centerline cell selection Evaluate the third cube coordinate symmetrically to retain the intended centerline tie. Keep the existing pixel oracle and tolerance. Scope the Mem fixture mutex unwrap allowance so the original strict CI can validate this base. Co-authored-by: Codex --- crates/gpu/src/shaders/fx_pixel2.wgsl | 4 +++- crates/script/src/tests_services.rs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index 21bb8b5d8..a01567540 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -647,7 +647,9 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3) { // stylize3::hex_cell (cube rounding). let q = (0.5773502691896257 * u - v / 3.0) / r; let rr = (2.0 / 3.0 * v) / r; - let ccy = -q - rr; + // Evaluate the third cube coordinate symmetrically with q so the centerline + // tie is not broken by subtracting separately rounded coordinates. + let ccy = (-0.5773502691896257 * u - v / 3.0) / r; var rx = round_away(q); let ry = round_away(ccy); var rz = round_away(rr); diff --git a/crates/script/src/tests_services.rs b/crates/script/src/tests_services.rs index c0ecfab40..eaabd2902 100644 --- a/crates/script/src/tests_services.rs +++ b/crates/script/src/tests_services.rs @@ -11,6 +11,8 @@ use crate::run_code; #[derive(Default)] struct Mem(Mutex>>); +// A poisoned fixture mutex should fail the test. +#[allow(clippy::unwrap_used)] impl Services for Mem { fn read_file(&self, path: &str) -> std::io::Result> { self.0.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string())) From 2fbcc6b59a4f5db5d9b5f4110b932893c691a26f Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Sun, 18 Oct 2026 22:28:56 +0800 Subject: [PATCH 0117/1386] ui: the interface prefers the platform's UI faces, rendered text keeps the print ones Windows ships a UI variant of its Japanese and Chinese families (`Meiryo`/`Meiryo UI`, `Yu Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`, `Microsoft JhengHei`/`Microsoft JhengHei UI`) whose kana and kanji are drawn for the screen, and its own link table for `Segoe UI` picks the UI one for everything it draws. The interface should ask for it - but the same lists feed `fonts::fallback_for`, which shapes composed frames, so reordering them would change how existing projects render. The two orders are therefore separate: this leaves `crates/text/src/fonts.rs` exactly as it is and gives the interface its own candidate list in `install_script_fallbacks`, which falls back to the shared choice when the platform has no UI variant, none is installed, or it does not cover the probe. `PMingLiU` is in the interface list for its proportional spacing, not as a sibling of `MingLiU`: it is MingLiU's proportional relative. The rendered order is pinned by a test now: the Japanese list keeps `Yu Gothic` ahead of `Yu Gothic UI`, and no UI name appears in the lists a document renders with. `the_interface_asks_for_the_ platforms_ui_faces` pins the interface list and checks that every other script the install probes has none, so a script that draws correctly in any face keeps the shared order. Review fixes: the locale is mapped once (the call passes the language, the helper maps it), the new test sits after the #394 test it had split from its doc comment, and the fonts.rs test asserts instead of panicking inside a closure. Rebased on current main; #1395 has not landed yet, so there is nothing to rebase after it. --- crates/text/src/fonts.rs | 24 +++++++++++++++++ crates/ui-egui/src/theme.rs | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..a2dd1fbdc 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1144,6 +1144,30 @@ mod tests { assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } + #[test] + fn the_rendered_order_keeps_the_print_face_first() { + // A composed frame renders with these lists, so they keep the platform's print order: the + // interface prefers the platform's UI faces on its own (see `install_script_fallbacks` in + // the ui crate), and an existing project must not render differently because a window's + // chrome changed. + let pos = |list: &[&str], name: &str| list.iter().position(|f| *f == name).unwrap_or(usize::MAX); + assert!( + pos(JAPANESE_FAMILIES, "Yu Gothic") < pos(JAPANESE_FAMILIES, "Yu Gothic UI"), + "the print face must come before its UI sibling: {JAPANESE_FAMILIES:?}" + ); + for name in ["Meiryo UI", "MS UI Gothic"] { + assert!(!JAPANESE_FAMILIES.contains(&name), "{name} belongs to the interface order, not this list"); + } + assert_eq!(SIMPLIFIED_CHINESE_FAMILIES[0], "Microsoft YaHei"); + assert_eq!(TRADITIONAL_CHINESE_FAMILIES[0], "Microsoft JhengHei"); + for name in ["Microsoft YaHei UI", "Microsoft JhengHei UI", "PMingLiU"] { + assert!( + !SIMPLIFIED_CHINESE_FAMILIES.contains(&name) && !TRADITIONAL_CHINESE_FAMILIES.contains(&name), + "{name} belongs to the interface order, not these lists" + ); + } + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..76d699ae9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -409,7 +409,15 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { - let id = fonts::fallback_for(probe, base); + // The interface asks for the platform's UI face first; the order a composed frame + // renders with is `fonts::fallback_for`'s and is not touched here. + let id = interface_fallback_candidates(name, language) + .iter() + .find_map(|family| { + let r = fonts::resolve(family, "Regular"); + (!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face) + }) + .unwrap_or_else(|| fonts::fallback_for(probe, base)); if seen.contains(&id) { continue; } @@ -432,6 +440,31 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) } } +/// The faces the interface asks for before it settles on the one a composed frame renders with. +/// +/// Windows ships a UI variant of its Japanese and Chinese families - `Meiryo`/`Meiryo UI`, `Yu +/// Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`, +/// `Microsoft JhengHei`/`Microsoft JhengHei UI` - and its own link table for `Segoe UI` +/// (`FontLink\SystemLink`) picks the UI one for what it draws on screen. `PMingLiU` is the one face +/// here that is not a sibling: it is `MingLiU`'s proportional relative, and proportional spacing is +/// what makes the chrome read better in it. +/// +/// Only the interface takes this order. `fonts::fallback_for` keeps the order a document renders +/// with, so an existing project's output does not move when the chrome changes. +#[cfg(not(target_arch = "wasm32"))] +fn interface_fallback_candidates(name: &str, language: &str) -> &'static [&'static str] { + match (name, cjk_locale(language)) { + // Kana, and the Han ideographs of a Japanese interface: the language asks for the Japanese + // face, so its UI sibling is the one the platform would draw chrome with. + ("japanese-system", _) | ("chinese-system", "ja") => &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"], + ("chinese-system", "zh-hant") => &["Microsoft JhengHei UI", "PMingLiU"], + ("chinese-system", "zh-hans") => &["Microsoft YaHei UI"], + // The other scripts have no UI variant, and their shared order already picks the face made + // for the script - the one that draws it correctly at all. + _ => &[], + } +} + /// The CJK locale the text engine should prefer for Han text: the interface language when it names /// one, otherwise the empty string, which leaves the operating system's locale in charge. #[cfg(not(target_arch = "wasm32"))] @@ -530,6 +563,7 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { #[cfg(all(test, not(target_arch = "wasm32")))] mod script_font_tests { + use super::{SCRIPT_PROBES, interface_fallback_candidates}; use effectcraft_text::fonts; /// Whether an installed face covers `probe` (else the glyph check is skipped). @@ -581,6 +615,7 @@ mod script_font_tests { /// #394: Arabic, Hebrew, Indic and Thai names are drawn by an installed face in an English /// interface: the UI fonts were Inter and the CJK fallbacks only, so a layer named in Arabic /// showed as missing-glyph boxes. + #[test] fn installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names() { // Letters only: a vowel sign or virama on its own is drawn on a dotted circle. @@ -595,6 +630,21 @@ mod script_font_tests { } } + #[test] + fn the_interface_asks_for_the_platforms_ui_faces() { + // The face a window draws its own chrome with, which is not the one a composed frame + // renders with: the two orders are deliberately separate. + let ja = &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"][..]; + assert_eq!(interface_fallback_candidates("japanese-system", "ja"), ja); + assert_eq!(interface_fallback_candidates("chinese-system", "ja"), ja); + assert_eq!(interface_fallback_candidates("chinese-system", "zh-hans"), &["Microsoft YaHei UI"][..]); + assert_eq!(interface_fallback_candidates("chinese-system", "zh-hant"), &["Microsoft JhengHei UI", "PMingLiU"][..]); + // Every other script the install probes has no UI variant, so it keeps the shared order. + for &(_, name) in SCRIPT_PROBES.iter() { + assert!(interface_fallback_candidates(name, "en").is_empty(), "{name} asked for a UI face"); + } + } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, /// so it became the Chinese fallback too, without the simplified-only hanzi. From c7d8824e09d110a7f8e0e84ffb1849ee635c7f7e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 01:21:57 -0400 Subject: [PATCH 0118/1386] docs: drop references to competing commercial software --- README.md | 51 +++++++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 914a40907..5d93cce41 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@

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.

@@ -64,13 +64,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 +85,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 +103,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,7 +119,7 @@ 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 +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, Lumetri Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, @@ -134,7 +135,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 +146,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,23 +220,23 @@ 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 third-party commercial plug-ins are not supported. +- **Fidelity is still being refined.** Automated testing across complex rendering pipelines is + ongoing, 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) +EffectCraft is in **alpha**: about 91% of planned core features 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 @@ -245,8 +246,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 @@ -368,8 +369,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. -

ArtCraft
Made by the ArtCraft team and community. From 14f4c56732c078ab16e52736696842c593bbbbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Sun, 18 Oct 2026 16:13:39 +0200 Subject: [PATCH 0119/1386] Cryptomatte: define CryptoAux, which the previous commit used but never added (#473) crypto_aux_image_with now takes CryptoAux::{ColorsAndMatte, MatteOnly} instead of a bool, and CryptoAux::for_display holds the rule for which displays read the ID colours (Colors without Matte Only). IdColorMemo uses get_mut instead of indexing, and the CPU effect zips the pixels with the aux index map instead of indexing it. No behaviour change. --- crates/effects/src/channel3d.rs | 70 +++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 26fbfe7a4..a77d4497e 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -408,14 +408,19 @@ struct IdColorMemo(Vec<(u32, [f32; 3])>); impl IdColorMemo { #[inline] fn get(&mut self, rank: usize, id: f32) -> [f32; 3] { - if self.0.len() <= rank { - self.0.resize(rank + 1, (id.to_bits(), id_color(id))); + let bits = id.to_bits(); + match self.0.get_mut(rank) { + Some(e) if e.0 == bits => e.1, + Some(e) => { + *e = (bits, id_color(id)); + e.1 + } + None => { + let c = id_color(id); + self.0.resize(rank + 1, (bits, c)); + c + } } - let e = &mut self.0[rank]; - if e.0 != id.to_bits() { - *e = (id.to_bits(), id_color(id)); - } - e.1 } } @@ -437,16 +442,37 @@ fn crypto_colors(planes: &RankPlanes, i: usize, memo: &mut IdColorMemo) -> [f32; /// selection's coverage (A), computed in parallel with the rank planes resolved once. The GPU /// effect uploads this image and samples it per layer pixel. pub fn crypto_aux_image(a: &AuxChannels, layer: &str, sel: &[u32]) -> Image { - crypto_aux_image_with(a, layer, sel, true) + crypto_aux_image_with(a, layer, sel, CryptoAux::ColorsAndMatte) } -/// [`crypto_aux_image`]; with `colors` false the RGB stays 0 and only the matte (A) is computed, -/// for the displays that never read the ID colours. -pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], colors: bool) -> Image { +/// What [`crypto_aux_image_with`] computes per aux pixel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CryptoAux { + /// The coverage-weighted ID colours (RGB) and the selection's coverage (A). + ColorsAndMatte, + /// Only the selection's coverage (A); the RGB stays 0. + MatteOnly, +} + +impl CryptoAux { + /// What the Cryptomatte effect reads for `display` and Matte Only: the ID colours are only + /// read by the Colors display (0) without Matte Only; Matte, Preview and Matte Only need the + /// matte alone. + pub fn for_display(display: u32, matte_only: bool) -> Self { + if display == 0 && !matte_only { Self::ColorsAndMatte } else { Self::MatteOnly } + } +} + +/// [`crypto_aux_image`]; with [`CryptoAux::MatteOnly`] the RGB stays 0 and only the matte (A) is +/// computed, for the displays that never read the ID colours. +pub fn crypto_aux_image_with(a: &AuxChannels, layer: &str, sel: &[u32], what: CryptoAux) -> Image { let planes = a.crypto_rank_planes(layer); let mut img = Image::new(a.width, a.height); img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| { - let c = if colors { crypto_colors(&planes, i, memo) } else { [0.0; 3] }; + let c = match what { + CryptoAux::ColorsAndMatte => crypto_colors(&planes, i, memo), + CryptoAux::MatteOnly => [0.0; 3], + }; *px = [c[0], c[1], c[2], crypto_matte(&planes, sel, i)]; }); img @@ -466,8 +492,8 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { let display = ctx.params.e("display"); let matte_only = ctx.params.b("matteOnly"); let planes = a.crypto_rank_planes(&layer); - b.img.data.par_iter_mut().enumerate().for_each_init(IdColorMemo::default, |memo, (i, px)| { - let m = idx[i].map_or(0.0, |k| crypto_matte(&planes, &sel, k)); + b.img.data.par_iter_mut().zip(idx.par_iter()).for_each_init(IdColorMemo::default, |memo, (px, &k)| { + let m = k.map_or(0.0, |k| crypto_matte(&planes, &sel, k)); if matte_only { *px = [m, m, m, m]; return; @@ -475,7 +501,7 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { *px = match display { // Colors: coverage-weighted ID colours (the spec's preview), selection brighter. 0 => { - let c = idx[i].map_or([0.0; 3], |k| crypto_colors(&planes, k, memo)); + let c = k.map_or([0.0; 3], |k| crypto_colors(&planes, k, memo)); let c = c.map(|v| v * (1.0 - 0.5 * m) + 0.5 * m); [c[0], c[1], c[2], 1.0] } @@ -884,13 +910,25 @@ mod tests { let a = edge_aux(true); let sel = edge_selection(); let full = crypto_aux_image(&a, "CryptoObject", &sel); - let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, false); + let matte = crypto_aux_image_with(&a, "CryptoObject", &sel, CryptoAux::MatteOnly); for (f, m) in full.data.iter().zip(&matte.data) { assert_eq!(f[3].to_bits(), m[3].to_bits()); assert_eq!(m[..3], [0.0; 3]); } } + /// Only the Colors display without Matte Only reads the ID colours. + #[test] + fn crypto_aux_for_display_reads_colors_only_for_the_colors_display() { + assert_eq!(CryptoAux::for_display(0, false), CryptoAux::ColorsAndMatte); + assert_eq!(CryptoAux::for_display(0, true), CryptoAux::MatteOnly); + for display in [1, 2, 3, u32::MAX] { + for matte_only in [false, true] { + assert_eq!(CryptoAux::for_display(display, matte_only), CryptoAux::MatteOnly, "display {display}"); + } + } + } + /// The CPU effect on a 20×10 layer over the 6×2 edge aux: pixels inside the aux area match /// the lookup by name bit for bit in every display mode, pixels outside it (no aux index) /// get an empty matte and no ID colour. From cb70a0cdbe4af41fe0653d17c80820fac9661775 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 10:42:59 -0400 Subject: [PATCH 0120/1386] docs: replace Lumetri effect reference with generic color grading --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5d93cce41..e1e7f87fa 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,8 @@ or into the Timeline, between layers and at the time you point to. ## Effects 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, Lumetri -Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, +defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Color +Grading…), 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…), From 99bdcde092b88f7ecfd99aa7f139aac95d8fcd34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 11:01:31 -0400 Subject: [PATCH 0121/1386] docs: add SonarCloud and CodeRabbit badges to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 914a40907..36a4dbbfc 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Status: young and moving fast Written in Rust License: MIT OR Apache-2.0 + Scanned by SonarCloud + Reviewed by CodeRabbit

From 4124ec6de7c6edd8e5e381217b26e5a8b84cac10 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 17:38:28 +0200 Subject: [PATCH 0122/1386] Marker names are drawn in a small outlined box, in theme colours A named marker drew its name as bare text beside the flag. After Effects puts it in a small outlined box. The box uses the theme's field fill, field outline and text tokens, so it follows Dark, Darker and Light instead of a fixed grey. It applies to every marker with a name, composition markers included; the dimmed names of nested-comp markers on a precomp layer's bar are unchanged. Split out of the WAV markers change (#1423), where the review asked for it to be its own decision. Test: ui_marker_names renders the timeline in Dark and in Light, samples the box's fill and outline pixels, and checks they equal that theme's tokens and differ between the themes. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/markers_ui.rs | 7 +++- crates/ui-egui/tests/ui_marker_names.rs | 51 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_marker_names.rs diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 2fff7e1c8..ca9a25967 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -298,7 +298,12 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: } p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { - p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); + // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field + // colours, so it follows Dark, Darker and Light. + let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); + let box_rect = Rect::from_min_size(pos2(x + 6.0, y0), vec2(galley.size().x + 8.0, h.max(galley.size().y + 2.0))); + p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); } let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1)); let id = egui::Id::new(("marker", r.layer, r.index)); diff --git a/crates/ui-egui/tests/ui_marker_names.rs b/crates/ui-egui/tests/ui_marker_names.rs new file mode 100644 index 000000000..b2eea20b4 --- /dev/null +++ b/crates/ui-egui/tests/ui_marker_names.rs @@ -0,0 +1,51 @@ +//! A marker's name is drawn in a small outlined box beside its flag, in the theme's own field colours (egui_kittest, a real +//! render): the fill and the outline follow Dark and Light, and the two themes differ. `MARKER_NAMES_SNAPSHOT=/abs/dir` +//! also saves the rendered panel for each theme. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::theme::ThemeKind; +use egui_kittest::Harness; +use serde_json::json; + +fn close(got: [u8; 4], want: egui::Color32) -> bool { + got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) +} + +#[test] +fn the_name_box_follows_the_theme() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse"})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(4); + let ctx = h.ctx.clone(); + + let mut seen = vec![]; + for (kind, name) in [(ThemeKind::Dark, "dark"), (ThemeKind::Light, "light")] { + h.state_mut().set_theme(&ctx, kind); + h.run_steps(4); + let tokens = h.state().tokens; + assert_eq!(tokens.kind, kind); + // the flag's hit area is 10 wide, centred on the marker; the box starts 6 points right of the centre + let hit = h.state().auto.find("timeline.marker.0").unwrap_or_else(|| panic!("the marker is on screen ({name})")).rect; + let (x, y0) = (hit[0] + hit[2] / 2.0, hit[1] + 1.0); + let h_flag = (hit[3] - 1.0).min(10.0); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join(format!("marker-name-{name}.png"))).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + h_flag / 2.0; + // inside the box, left of the text: the field fill; on its left edge: the field outline + let fill = at(x + 6.0 + 2.5, mid); + let edge = at(x + 6.0 + 0.5, mid); + assert!(close(fill, tokens.field_bg), "{name}: box fill {fill:?}, the theme's field_bg is {:?}", tokens.field_bg); + assert!(close(edge, tokens.field_border), "{name}: box outline {edge:?}, the theme's field_border is {:?}", tokens.field_border); + seen.push((fill, edge)); + } + assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey"); +} From bccbc666a235ab4eba2ad0ad0b4c22b88e60bd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:36:16 +0000 Subject: [PATCH 0123/1386] #1417: WAV markers show on the audio layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WAV's markers were never read, so its audio layer showed none. The importer now reads the file's cue points (`cue ` chunk named by `LIST/adtl` `labl` and `note`, sized by `ltxt`) and Adobe's XMP markers (`_PMX` chunk, `xmpDM:markers` entries as elements or attributes, in frames of the track's `xmpDM:frameRate`). A marker present in both encodings is listed once. The reader is bounds-checked and caps the marker count and XMP size. - `Footage.markers` (optional in the file format) holds them at footage time; a layer made from the footage starts with them. - Layer ▸ Markers ▸ Update Markers From Source also works on footage layers. - Markers from a file have no label colour. Tests: reader unit tests (cue, note, ltxt, both XMP forms, both encodings, truncated and hostile files), a probe test, and an end-to-end test through the fully wired session. Not checked against a WAV written by Adobe's tools. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/engine/src/commands/markers.rs | 15 +- crates/host/tests/wav_markers.rs | 75 ++++++ crates/media/Cargo.toml | 1 + crates/media/src/lib.rs | 1 + crates/media/src/probe.rs | 3 + crates/media/src/wav_markers.rs | 350 ++++++++++++++++++++++++++ crates/project/src/build.rs | 10 +- crates/project/src/lib.rs | 5 + 9 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 crates/host/tests/wav_markers.rs create mode 100644 crates/media/src/wav_markers.rs diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..243837d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1443,6 +1443,7 @@ name = "effectcraft-media" version = "0.12.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/commands/markers.rs index 4c979619e..35ab7123f 100644 --- a/crates/engine/src/commands/markers.rs +++ b/crates/engine/src/commands/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -339,10 +344,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs new file mode 100644 index 000000000..6e3943915 --- /dev/null +++ b/crates/host/tests/wav_markers.rs @@ -0,0 +1,75 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again. + +use effectcraft_project::LayerSource; +use serde_json::json; + +/// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). +fn marker_wav() -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let points = [(1u32, 0u32, "Intro"), (2, 4000, "Middle")]; + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for (id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav()).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..52a375fef 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..18609ecf5 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2793be251..12ca2d207 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -98,8 +98,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); Ok(f) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..f7c1bb84d --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,350 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers and the size of the XMP packet are capped. + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + for m in xmp_markers(xmp, rate) { + // Tools that write both encodings repeat each marker. + if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // Cue id -> (name, length in samples). + let mut names: Vec<(u32, String)> = Vec::new(); + let mut lengths: Vec<(u32, u32)> = Vec::new(); + for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + if let Some(cue_id) = u32_at(body, 0) { + match &id { + b"labl" | b"note" => { + let text = label(body.get(4..).unwrap_or_default()); + // A `labl` is the name; a `note` only when there is none. + let known = names.iter().any(|(c, _)| *c == cue_id); + if &id == b"labl" || !known { + names.retain(|(c, _)| *c != cue_id); + names.push((cue_id, text)); + } + } + b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + let mut out = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); + let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); + out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); + } + out +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..1b2f12c81 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -854,6 +854,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -883,7 +891,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a4bffbe0c..4cdb3ac24 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1172,6 +1172,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -1207,6 +1211,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } From 6c6b54a182d5cbf3737981ec4fc117a5afe0c695 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 18 Oct 2026 19:54:25 +0400 Subject: [PATCH 0124/1386] Georgian interface text draws from an installed font Georgian joins the installed-font fallback like Thai, Hebrew and the Indic scripts: Sylfaen on Windows, SF Georgian on macOS, Noto Sans Georgian or DejaVu Sans on Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..11a436f22 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -542,6 +542,8 @@ enum FallbackScript { /// Sinhala (one family covers them all on Windows; elsewhere each has its own). Indic, Thai, + /// Georgian: Mkhedruli, Asomtavruli and Nuskhuri, and Mtavruli capitals. + Georgian, Other, } @@ -570,6 +572,7 @@ fn script_of(c: char) -> FallbackScript { 0x0590..=0x05FF | 0xFB1D..=0xFB4F => FallbackScript::Hebrew, 0x0900..=0x0DFF | 0xA8E0..=0xA8FF => FallbackScript::Indic, 0x0E00..=0x0E7F => FallbackScript::Thai, + 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => FallbackScript::Georgian, _ => FallbackScript::Other, } } @@ -635,6 +638,7 @@ const INDIC_FAMILIES: &[&str] = &[ "Noto Sans Sinhala", ]; const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Sans Thai UI", "Loma", "Garuda"]; +const GEORGIAN_FAMILIES: &[&str] = &["Sylfaen", ".SF Georgian", "SF Georgian", "Noto Sans Georgian", "DejaVu Sans"]; /// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]). static REQUESTED_CJK_LOCALE: RwLock> = RwLock::new(None); @@ -719,6 +723,7 @@ fn families_for(s: FallbackScript) -> &'static [&'static str] { FallbackScript::Hebrew => HEBREW_FAMILIES, FallbackScript::Indic => INDIC_FAMILIES, FallbackScript::Thai => THAI_FAMILIES, + FallbackScript::Georgian => GEORGIAN_FAMILIES, FallbackScript::Other => &[], } } @@ -981,6 +986,8 @@ mod tests { assert_eq!(script_of('क'), FallbackScript::Indic); assert_eq!(script_of('த'), FallbackScript::Indic); assert_eq!(script_of('ก'), FallbackScript::Thai); + assert_eq!(script_of('ა'), FallbackScript::Georgian); + assert_eq!(script_of('\u{1c90}'), FallbackScript::Georgian, "Mtavruli"); assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter"); } @@ -1004,6 +1011,22 @@ mod tests { } } + /// Native: the script search picks a Georgian family for Mkhedruli (a face already loaded + /// that happens to cover it, such as macOS Helvetica, still wins in `fallback_for`). Skipped + /// where no family from the list is installed. + #[test] + fn georgian_falls_back_to_a_georgian_system_family() { + scan_system(); + let c = 'ა'; + if !GEORGIAN_FAMILIES.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c))) { + eprintln!("no Georgian family from the list is installed; skipping"); + return; + } + let f = face(system_fallback(c, "Regular").unwrap()); + assert!(f.has_char(c), "{} lacks it", f.info.family); + assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family); + } + /// Native: Japanese text in a Latin-only family falls back to an installed Japanese family /// (whichever the platform ships) instead of the missing-glyph box, without any layer having /// loaded a CJK font first. Skipped where no family from the list is installed. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..c8aaafed7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -370,6 +370,7 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ ('ಕ', "kannada-system"), ('ക', "malayalam-system"), ('ก', "thai-system"), + ('ა', "georgian-system"), ]; /// Register the system fallbacks for scripts the bundled fonts lack in every font family, and @@ -595,6 +596,17 @@ mod script_font_tests { } } + /// The Georgian interface draws its Mkhedruli text with an installed Georgian face (Sylfaen, + /// SF Georgian, Noto Sans Georgian…): Inter has no Georgian, so the labels showed as boxes. + #[test] + fn installed_georgian_fallback_draws_the_georgian_interface() { + if !installed('ა') { + eprintln!("no Georgian system font installed; skipping glyph coverage"); + return; + } + assert_drawn("ka", "კომპოზიციაფენასაკვანძოკადრიეფექტიპარამეტრები"); + } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, /// so it became the Chinese fallback too, without the simplified-only hanzi. From 4fa31b5e5b9fefa912d364c8f1f5dbffb3c2ea0e Mon Sep 17 00:00:00 2001 From: Charles Baumer <343171694+CharlesBaumer@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:12:55 +0000 Subject: [PATCH 0125/1386] Engine tests: clippy passes on Windows (Stills is Unix-only) `Stills` is only used by the `#[cfg(unix)]` part of `footage_on_other_computers_is_never_looked_at`. On Windows it was dead code, so `cargo xtask ci` stopped at clippy (`-D warnings`). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/doc_path/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index b8dcbc190..74680df72 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -34,9 +34,11 @@ impl Services for Recording { } } -/// Probes every file as a still. +/// Probes every file as a still. Only the Unix part of the test below imports files. +#[cfg(unix)] struct Stills; +#[cfg(unix)] impl crate::Importer for Stills { fn probe(&self, path: &str) -> Result { if !std::path::Path::new(path).is_file() { From bd961131a455dafe5112e5f38d0026ff612b2bcf Mon Sep 17 00:00:00 2001 From: Charles Baumer <343171694+CharlesBaumer@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:23:28 +0000 Subject: [PATCH 0126/1386] Media tests: ffmpeg fixtures survive concurrent first runs on Windows Tests that share a fixture each generate it on a first run and rename their temp file into place. On Windows the losing rename fails with PermissionDenied while the winner has the file open. Treat a failed rename as "another test made it first" when the fixture exists, as `png_sequence` already does. Co-Authored-By: Claude Opus 5.5 --- crates/media/tests/footage.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..8fbd6e970 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option { let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], "")); let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg"); assert!(st.success(), "ffmpeg failed for {name}"); - std::fs::rename(&tmp, &out).expect("rename"); + if std::fs::rename(&tmp, &out).is_err() { + // Another test made it first; on Windows the rename fails while that test has it open. + let _ = std::fs::remove_file(&tmp); + assert!(out.exists(), "rename {} to {name} failed", tmp.display()); + } Some(out) } From 20c03451c0d752c3860f805885f833565788d204 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 12:51:59 -0400 Subject: [PATCH 0127/1386] docs: describe color grading panel, scope SDK plug-in warning, qualify rendering tests, and link parity checklist --- README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e1e7f87fa..d403ac649 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,8 @@ or into the Timeline, between layers and at the time you point to. ## Effects 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, Color -Grading…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, +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…), @@ -226,19 +226,20 @@ case. Today it's a good place to try things and to tell us what breaks. It isn't for production client work. In particular: - **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary - project formats and third-party commercial plug-ins are not supported. -- **Fidelity is still being refined.** Automated testing across complex rendering pipelines is - ongoing, so please report visual discrepancies or unexpected behaviour. + 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). Both are actively undergoing evaluation and tuning. -EffectCraft is in **alpha**: about 91% of planned core features 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. +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. From ef60d3db9dde0f9e5fe07fd07f38bed1dfc46d51 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:58:09 -0400 Subject: [PATCH 0128/1386] Preview: a slow first frame keeps the opening sound (#947) Under load the first frame could take longer than STARTUP_WAIT to be cached. The start then gave up, started the sound on that uncached frame, and the stall path stopped it in the same step, so the opening played silently. STARTUP_WAIT (2 s) now counts from when the first frame is cached, and only bounds the wait for the frames ahead. A new STARTUP_LIMIT (10 s from Play) still ends the wait for a first frame that never gets cached. The sound is never started on a frame that isn't cached: the preview goes on as a stalled one does, and the sound starts once frames are in. Tests (ui_audio_scrub): a first frame held uncached for 3 s still gets its sound from frame 0, and the device keeps playing (fails on the previous commit). The two give-up tests now check that no sound is started and dropped. --- crates/ui-egui/src/lib.rs | 40 ++++++++++++++----- crates/ui-egui/tests/ui_audio_scrub.rs | 53 ++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 16630ed23..b41011178 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,12 +52,18 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0; /// [`EffectcraftApp::editing`]). const EDIT_QUIET: f64 = 0.3; -/// Longest a preview with sound holds its first frame waiting for frames (seconds, #947). A frame -/// that never gets cached (larger than the RAM preview, which the size estimate can't always -/// tell) mustn't hold it for ever: the sound starts from the first frame anyway, and the preview -/// goes on as a stalled one does. +/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the +/// frames ahead (seconds, #947). A frame ahead that never gets cached mustn't hold it for ever: +/// the sound starts from the first frame anyway, and the preview goes on as a stalled one does. const STARTUP_WAIT: f64 = 2.0; +/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame +/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from +/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size +/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled +/// one does: its sound starts once the frames are cached. +const STARTUP_LIMIT: f64 = 10.0; + /// How many frames from the first a preview with sound waits for before it starts (#947): the /// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets /// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at @@ -240,8 +246,11 @@ pub struct Playback { /// while they rendered and the sound started at the later frame, so the opening was lost. /// A stall later on still falls back to [`Playback::audio_held`]. pub starting: bool, - /// When the preview started (seconds): [`Playback::starting`] gives up after [`STARTUP_WAIT`]. + /// When the preview started (seconds): [`Playback::starting`] gives up after [`STARTUP_LIMIT`]. pub starting_since: f64, + /// When the first frame was cached while [`Playback::starting`] (seconds): the wait for the + /// frames ahead gives up [`STARTUP_WAIT`] after it. + pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, } @@ -1031,6 +1040,7 @@ impl EffectcraftApp { audio_held: false, starting: false, starting_since: now, + first_cached: None, shown_at: Default::default(), }; if !self.playback.caching { @@ -1307,18 +1317,28 @@ impl EffectcraftApp { let [w, h] = img.size(); startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4)) }); - // Don't wait for frames the RAM preview can't hold (not even one frame fits), nor for - // longer than STARTUP_WAIT (a frame that never gets cached). - let give_up = (self.frames.budget() as f64) < self.frame_bytes(&c, scale) || now - self.playback.starting_since >= STARTUP_WAIT; + if need.is_some() && self.playback.first_cached.is_none() { + self.playback.first_cached = Some(now); + } + // Don't wait for frames the RAM preview can't hold (not even one frame fits), for the + // frames ahead longer than STARTUP_WAIT once the first is in, nor longer than + // STARTUP_LIMIT in all (a frame that never gets cached). + let give_up = (self.frames.budget() as f64) < self.frame_bytes(&c, scale) + || self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT) + || now - self.playback.starting_since >= STARTUP_LIMIT; if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) { self.playback.waiting = true; ctx.request_repaint(); return; } self.playback.starting = false; - self.playback.audio_held = false; self.playback.waiting = false; - self.start_audio(fr.tick_of(cur)); + // Not on a frame that isn't cached: the stall below would stop the sound at once. The + // preview goes on as a stalled one does, and the sound starts once frames are cached. + if need.is_some() { + self.playback.audio_held = false; + self.start_audio(fr.tick_of(cur)); + } } // Held sound starts once the frames ahead are cached again after a stall (see // `Playback::audio_held`): the prefetch window when the silent preview keeps real time, diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index c01b6bb2d..a9b400662 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -377,6 +377,41 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() { assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); } +/// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; +/// here it can't be cached until the RAM preview grows): the start keeps holding it, then the +/// sound starts from it and keeps playing. It used to give up two seconds after Play, start the +/// sound on the uncached frame and stop it at once, so the opening played silently. +#[test] +fn a_slow_first_frame_keeps_its_opening_sound() { + let fake = Fake::default(); + let (mut h, cid, size) = extended_viewer(&fake); + let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); + assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp"); + h.state().frames.set_budget((comp_bytes + real_bytes) / 2); + h.state().frames.clear(); + let start = h.ctx.input(|i| i.time); + h.state_mut().play(start); + while h.ctx.input(|i| i.time) - start < 3.0 { + h.step(); + assert!(h.state().playback.starting, "it still holds the first frame"); + assert_eq!(frame(&h, cid), 0); + } + assert!(fake.0.lock().unwrap().0.is_none(), "no sound yet"); + h.state().frames.set_budget(1 << 30); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while h.state().playback.starting && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); + assert_eq!(audio_frame(&h, cid), Some(0), "with its sound from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); +} + /// An output device whose start fails. #[derive(Clone, Default)] struct Broken; @@ -460,7 +495,8 @@ fn a_started_preview_follows_its_sound() { } /// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait -/// for frames that can never be cached; the sound starts at once from the first frame. +/// for frames that can never be cached. The sound isn't started on a frame that can't be shown +/// (a stall would stop it at once): the preview goes on as a stalled one does. #[test] fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { for budget in [0, 9216 / 2] { @@ -474,16 +510,16 @@ fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() { h.state_mut().play(now); h.step(); assert!(!h.state().playback.starting, "budget {budget}: no wait"); - assert!(fake.0.lock().unwrap().0.is_some(), "budget {budget}: the sound started"); - assert!(h.state().playback.playing); + assert!(fake.0.lock().unwrap().0.is_none(), "budget {budget}: no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "budget {budget}: it goes on as a stalled preview"); } } /// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame -/// larger than the whole budget): the start gives up after a short wait instead of holding the -/// first frame for ever, and the sound starts from it. +/// larger than the whole budget): the start gives up after STARTUP_LIMIT instead of holding the +/// first frame for ever, without starting the sound on a frame it can't show. #[test] -fn startup_gives_up_after_a_short_wait() { +fn startup_gives_up_on_a_first_frame_that_never_caches() { let fake = Fake::default(); let (mut h, cid, size) = extended_viewer(&fake); let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4); @@ -501,6 +537,7 @@ fn startup_gives_up_after_a_short_wait() { } let waited = h.ctx.input(|i| i.time) - start; assert!(!h.state().playback.starting, "it gave up waiting"); - assert!((1.9..4.0).contains(&waited), "after about two seconds ({waited:.2} s)"); - assert!(fake.0.lock().unwrap().0.is_some(), "and the sound started"); + assert!((9.9..12.0).contains(&waited), "after about ten seconds ({waited:.2} s)"); + assert!(fake.0.lock().unwrap().0.is_none(), "no sound started and dropped"); + assert!(h.state().playback.playing && h.state().playback.audio_held, "it goes on as a stalled preview"); } From 70ad68eb85531fa1bf38384013453f5ac1134cc1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:58:09 -0400 Subject: [PATCH 0129/1386] Preview: the Extended Viewer startup test doesn't race the first frame (#947) startup_finishes_when_extended_viewer_frames_are_larger failed on busy machines. The harness clock moves 1/30 s per step however long a frame takes, so a slow first frame could use up the startup's limits in harness time. The test now waits for the first frame in real time before stepping on, as it is about the wait for the frames ahead, and checks the fake device was started and not stopped. --- crates/ui-egui/tests/ui_audio_scrub.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index a9b400662..6b79e9838 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -372,9 +372,28 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() { assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); h.state().frames.set_budget(budget); h.state().frames.clear(); - assert!(until_started(&mut h), "the preview left its startup"); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + h.step(); + // The first frame renders before the harness clock moves on: each step is 1/30 s however long + // it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that + // clock, and this test is about the wait for the frames ahead. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + while h.state().playback.starting && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); assert!(h.state().audio.is_some(), "with its sound"); assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); } /// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; From f52ffb63ceb762638fb866a6074e8faae324b9c2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:59:24 -0400 Subject: [PATCH 0130/1386] Viewer: no proxy notice on a collapsed precomp, which draws its own layers (#1701) --- crates/ui-egui/src/panels/viewer.rs | 28 +++++++++++-------- crates/ui-egui/tests/ui_unreadable_footage.rs | 26 ++++++++++++++++- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 795ac0650..bec2440d6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; -use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; +use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -1194,16 +1194,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder - // for its codec / profile): say why on the layer instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { - continue; - }; - let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; - if notices >= 3 { - break; - } + // for its codec / profile): say why on the layer instead of leaving a black picture. A + // collapsed precomp draws its own layers, never the comp's proxy. + let problems: Vec<(&Layer, String)> = { + let r = Renderer::new(&snap_project, &*app.session.footage, Default::default()); + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(&ectx, l).is_none()) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, snap_project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() + }; + for (notices, (layer, why)) in problems.into_iter().enumerate() { let (m, _) = l2c(&ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -1215,7 +1220,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } // Free transform box of a path (double-click a path with the Selection tool). diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index b1b12d6d2..63e3a75c2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -45,10 +45,14 @@ fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'st item.proxy = Some(Box::new(Proxy { footage, enabled: true })); } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] @@ -78,3 +82,23 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } + +/// A precomp whose proxy can't be decoded: the layer says so while it renders through the +/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +#[test] +fn a_collapsed_precomp_has_no_notice_for_its_proxy() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + } +} From 3b9d243ac516c790aa4e7905b6e0cd003426b157 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Sun, 18 Oct 2026 22:46:28 +0530 Subject: [PATCH 0131/1386] =?UTF-8?q?Shift=20Channels:=20After=20Effects'?= =?UTF-8?q?=20Take=20=E2=80=A6=20From=20menu=20order,=20so=2010=20is=20Ful?= =?UTF-8?q?l=20Off=20(#1797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu had an extra "Half On" at position 10, where After Effects has "Full Off", so a project or script written for After Effects set a channel to 50 % grey instead of turning it off, with no error: the RGB-split recipe (three copies, each keeping one channel and setting the other two to Full Off, blended with Add) rendered pastel instead of pure red, green and blue. After Effects' ten options now come first, in its order, and "Half On" sits last as an EffectCraft extra, so values 1 to 10 mean what they mean in After Effects and nothing is lost. Instances saved with the old order keep the option they chose: upgrade_instance already remaps popup values by label. The GPU path keeps its own copy of the table, so it is reordered to match; a GPU-vs-CPU parity case now covers the constant sources at the end of the menu, where the two tables could drift apart unnoticed. Both paths clamped the index to a hardcoded 10 and now use SHIFT_ORDER.len() - 1, so the clamp cannot go stale if the list changes again. Co-Authored-By: Claude Opus 5 --- crates/effects/src/channel.rs | 27 ++++++++++++++++++++++++--- crates/effects/src/migrate.rs | 16 ++++++++++++++++ crates/gpu/src/fx_key.rs | 7 +++++-- crates/gpu/src/tests_fx_key.rs | 3 +++ 4 files changed, 48 insertions(+), 5 deletions(-) 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/migrate.rs b/crates/effects/src/migrate.rs index 0e037022e..b5976b761 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -499,6 +499,22 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #1797: Shift Channels' menu put Half On before Full Off; a project saved then keeps the + /// option it chose, by label. + #[test] + fn shift_channels_half_on_and_full_off_are_remapped_by_label() { + const OLD: [&str; 11] = ["Alpha", "Red", "Green", "Blue", "Luminance", "Hue", "Lightness", "Saturation", "Full On", "Half On", "Full Off"]; + for (saved, want) in [(9u32, 10u32), (10, 9), (1, 1)] { + let mut next = 1; + let (spec, mut g) = instance("ec.channel.shiftchannels", &mut next); + let pr = g.get_mut("takeGreenFrom").unwrap(); + pr.ui = ParamUi::Popup { options: OLD.iter().map(|s| s.to_string()).collect() }; + pr.value = Value::Enum(saved); + upgrade_instance(spec, &mut g, &mut Ids(&mut next), SIZE); + assert_eq!(g.get("takeGreenFrom").unwrap().value, Value::Enum(want), "saved {saved}"); + } + } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were /// plain text; old projects get the Choose… file parameter, keeping the path. #[test] diff --git a/crates/gpu/src/fx_key.rs b/crates/gpu/src/fx_key.rs index 1099bccd1..3bb5a87ab 100644 --- a/crates/gpu/src/fx_key.rs +++ b/crates/gpu/src/fx_key.rs @@ -438,8 +438,10 @@ fn refine(e: &mut Enc, ctx: &EffectCtx, b: GBuf, hard: bool) -> Option { const SET_CHANNEL_LAYERS: [&str; 4] = ["sourceLayer1", "sourceLayer2", "sourceLayer3", "sourceLayer4"]; +/// Shift Channels' Take … From menu, in the order of `effects`' `SHIFT_ORDER`: the two must agree, +/// or the source depends on whether the effect renders on the GPU. 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]; + [Src::Alpha, Src::Red, Src::Green, Src::Blue, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off, Src::Half]; const MATTE_ORDER: [Src; 10] = [Src::Red, Src::Green, Src::Blue, Src::Alpha, Src::Luminance, Src::Hue, Src::Lightness, Src::Saturation, Src::Full, Src::Off]; @@ -527,7 +529,8 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [level, pr.f("softness").clamp(0.0, 100.0) as f32 / 100.0, 0.0, 0.0]; } "ec.channel.shiftchannels" => { - let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"].map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(10)])); + let s = ["takeRedFrom", "takeGreenFrom", "takeBlueFrom", "takeAlphaFrom"] + .map(|id| src_index(SHIFT_ORDER[(pr.e(id) as usize).min(SHIFT_ORDER.len() - 1)])); return route(e, b, s); } "ec.channel.removecolormatting" => { diff --git a/crates/gpu/src/tests_fx_key.rs b/crates/gpu/src/tests_fx_key.rs index 5ece27e69..35303bb0b 100644 --- a/crates/gpu/src/tests_fx_key.rs +++ b/crates/gpu/src/tests_fx_key.rs @@ -155,6 +155,9 @@ fn refine_mattes() { fn channel_effects() { effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]); effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]); + // #1797: the constant sources at the end of the menu, where the GPU's order could drift from + // the CPU's (Full On, Full Off, Half On). + effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(8)), ("takeGreenFrom", e(9)), ("takeBlueFrom", e(10))]); effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]); effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]); for op in [0, 2, 3, 4, 8, 10, 12] { From 75fce6b2d8828158fdb527f2fc9ab4cddd404a16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:47:37 -0400 Subject: [PATCH 0132/1386] docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 36a4dbbfc..3582317cf 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Status: young and moving fast Written in Rust License: MIT OR Apache-2.0 +
Scanned by SonarCloud Reviewed by CodeRabbit

From 791e6bb5ebff409e5d8196db43fa87f25e836056 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Mon, 19 Oct 2026 01:59:48 +0530 Subject: [PATCH 0133/1386] fix(engine): preserve alpha channel when saving still PNG frames Exporting still frames via comp.saveFrameAs previously flattened the rendered frame against the composition background color, stripping the alpha channel. Single still frames queued in the Render Queue also defaulted to an RGB-only PNG sequence output module. Preserve straight alpha by default in comp.saveFrameAs (while honoring transparent: false if requested), and set TemplateSlot::Still to default to 'PNG Sequence with Alpha'. Fixes #1815 --- crates/engine/src/commands/comp_more.rs | 2 +- crates/engine/src/tests_frame_export.rs | 36 +++++++++++++++++++++++++ crates/project/src/render_templates.rs | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index d6de320ec..96aa29a9a 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -117,7 +117,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if p.get("transparent").and_then(Value::as_bool) == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved frame to {path}")); diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index a45c5d444..2aca8ce36 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -164,3 +164,39 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() { assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); } + +#[test] +fn save_frame_as_png_preserves_alpha_channel() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap(); + let dir = out_dir("frame-png-alpha"); + let path = dir.join("alpha.png").to_string_lossy().to_string(); + + s.execute("comp.saveFrameAs", json!({"path": path})).unwrap(); + let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8(); + assert_eq!((img.width(), img.height()), (32, 32)); + let solid_px = img.get_pixel(8, 8); + assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}"); + assert_eq!(solid_px[0], 255); + let empty_px = img.get_pixel(24, 24); + assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}"); + + let flat_path = dir.join("flat.png").to_string_lossy().to_string(); + s.execute("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap(); + let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8(); + let flat_empty_px = flat_img.get_pixel(24, 24); + assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}"); +} + +#[test] +fn save_frame_as_defaults_to_png_sequence_with_alpha() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let r = s.execute("comp.saveFrameAs", json!({"queue": true})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); +} diff --git a/crates/project/src/render_templates.rs b/crates/project/src/render_templates.rs index cf52b000e..8de342f4c 100644 --- a/crates/project/src/render_templates.rs +++ b/crates/project/src/render_templates.rs @@ -88,7 +88,7 @@ impl Default for TemplateDefaults { ]), output_modules: s(&[ (Movie, "H.264 - Match Render Settings - 15 Mbps"), - (Still, "PNG Sequence"), + (Still, "PNG Sequence with Alpha"), (PreRender, "Lossless with Alpha"), (MovieProxy, "Lossless with Alpha"), (StillProxy, "PNG Sequence with Alpha"), From aacf09a5ca5e96c2a46205952e3d7a0b18d83ecd Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Mon, 19 Oct 2026 02:07:17 +0530 Subject: [PATCH 0134/1386] fix(render): use pixel units for text animator tracking amount In After Effects, text animator Tracking Amount adds the value in absolute pixels between neighbouring characters, independent of font size. EffectCraft previously scaled tracking by 1/1000 em (matching static Character-panel tracking), causing tracking reveals to have significantly reduced spread. Apply Tracking Amount directly in pixels during glyph positioning layout, update existing tracking tests to reflect pixel units, and add a regression test verifying font-size independence. Fixes #1798 --- crates/render/src/text.rs | 7 +++---- crates/render/src/text/tests.rs | 35 ++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..3ca2d6e75 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,7 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) added before / after the character. + /// Tracking (pixels) added before / after the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -639,10 +639,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + pen += x.track_before; shift[gi] = pen; - pen += x.track_after * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..9ddf5421c 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -202,9 +202,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + // 4 chars with 20 px tracking: the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -244,10 +244,10 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + // 4 chars with 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; - let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -259,11 +259,36 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } +/// #1798: After Effects adds Tracking Amount in absolute pixels between neighbouring characters, +/// independent of font size. +#[test] +fn text_animator_tracking_amount_uses_pixels_independent_of_font_size() { + let width_diff = |font_size: f64, amount: f64| { + let (mut p, cid, comp) = setup(2000, 400); + let mut l = text_layer(&mut p, &comp, doc("ABCDE", font_size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let w = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])).width(); + let (mut p0, cid0, comp0) = setup(2000, 400); + let l0 = text_layer(&mut p0, &comp0, doc("ABCDE", font_size)); + p0.comp_mut(cid0).unwrap().layers.push(l0); + let w0 = bounds_of(&glyph_paths(&ctx(&p0, cid0, 0.0), &p0.comp(cid0).unwrap().layers[0])).width(); + w - w0 + }; + // 5 characters = 4 gaps. In After Effects, Tracking Amount 100 adds 100 px per gap = 400 px total width delta, + // regardless of whether font size is 50 px or 200 px. + let diff_50 = width_diff(50.0, 100.0); + let diff_200 = width_diff(200.0, 100.0); + assert!((diff_50 - 400.0).abs() < 1.0, "font_size 50 px width delta: {diff_50}"); + assert!((diff_200 - 400.0).abs() < 1.0, "font_size 200 px width delta: {diff_200}"); +} + #[test] fn anchor_grouping_line_rotates_around_line_centre() { let (mut p, cid, comp) = setup(600, 300); From f7922a9fbaaeb43ee2729b5608a821450acc272f Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Mon, 19 Oct 2026 02:11:05 +0530 Subject: [PATCH 0135/1386] test(engine): add undo and redo assertions for queued still frame export --- crates/engine/src/tests_frame_export.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index 2aca8ce36..d56c941ae 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -199,4 +199,10 @@ fn save_frame_as_defaults_to_png_sequence_with_alpha() { let it = s.project.render_queue.last().unwrap(); assert_eq!(it.output.name, "PNG Sequence with Alpha"); assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); } From 88f940d54ee9e31e45bd407e170044fec2f184d2 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 17:05:03 -0400 Subject: [PATCH 0136/1386] Export: CPU cap bounds images waiting to encode and survives a GPU stop (#1615) Review of #1763: - With EFFECTCRAFT_CPU_INFLIGHT set, each window is the CPU cap instead of one frame per thread, so rendered images held before encoding count against the cap too (a cap of 2 on 16 threads held up to 16 images). - Once the GPU stops for good, the GPU cap lapses and the CPU cap alone applies, instead of the smaller cap (often the GPU default of 1) holding for the rest of the batch. The caps are now an `Inflight { gpu, cpu }` that `map_frames_while` re-evaluates per window and chunk. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 165 ++++++++++++++++++++++++++-------- 1 file changed, 129 insertions(+), 36 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index bd80a2a6c..63200b40d 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -62,14 +62,31 @@ pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { } } -/// The frames-in-flight cap of a batch, and whether it lapses once the GPU stops for good: -/// a GPU cap alone lapses (the CPU fallback renders in parallel, #1511), a CPU cap never does, -/// and with both the smaller one holds for the whole batch. -pub(crate) fn inflight_cap(gpu: Option, cpu: Option) -> (Option, bool) { - match (gpu, cpu) { - (Some(g), None) => (Some(g), true), - (Some(g), Some(c)) => (Some(g.min(c)), false), - (None, c) => (c, false), +/// The frames-in-flight caps of a batch: `gpu` (#879) for the GPU's memory, `cpu` (#1615) for +/// the process's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Inflight { + pub gpu: Option, + pub cpu: Option, +} + +impl Inflight { + /// Frames rendered at once: the smaller cap while the GPU renders, and only the CPU cap once + /// it has stopped for good (the CPU fallback renders in parallel, #1511). `None`: uncapped. + pub fn renders(&self, gpu_live: bool) -> Option { + let gpu = self.gpu.filter(|_| gpu_live); + let n = match (gpu, self.cpu) { + (Some(g), Some(c)) => Some(g.min(c)), + (g, c) => g.or(c), + }; + n.map(|n| n.max(1)) + } + + /// Frames per window, rendered (`renders` at a time) and then encoded in parallel: with a CPU + /// cap at most that many, so finished images held before encoding count against it too; + /// with a GPU cap alone, one per thread (the images are in RAM, not VRAM). + pub fn window(&self, renders: usize) -> usize { + self.cpu.map_or(renders.max(rayon::current_num_threads()), |c| c.max(1)) } } @@ -89,38 +106,41 @@ pub(crate) fn map_frames( render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { - map_frames_while(ks, cap, || true, render, encode) + map_frames_while(ks, Inflight { gpu: cap, cpu: None }, || true, render, encode) } -/// [`map_frames`] whose `cap` holds only while `capped()`: once it turns false (the GPU stopped -/// for good, so every frame falls back to the CPU), the remaining frames render in parallel -/// like the uncapped CPU path instead of one chunk of `cap` at a time. +/// [`map_frames`] with the caps of `caps`, the GPU cap holding only while `gpu_live()`: once it +/// turns false (the GPU stopped for good, so every frame falls back to the CPU), the remaining +/// frames render under the CPU cap alone, in parallel when there is none, instead of one chunk +/// of the GPU cap at a time. A CPU cap also bounds each window ([`Inflight::window`]). pub(crate) fn map_frames_while( ks: Vec, - cap: Option, - capped: impl Fn() -> bool, + caps: Inflight, + gpu_live: impl Fn() -> bool, render: impl Fn(u64) -> I + Sync + Send, encode: impl Fn(u64, I) -> T + Sync + Send, ) -> Vec { use rayon::prelude::*; - let Some(n) = cap.map(|n| n.max(1)).filter(|_| capped()) else { + if caps.renders(gpu_live()).is_none() { return ks.into_par_iter().map(|k| encode(k, render(k))).collect(); - }; - let window = n.max(rayon::current_num_threads()); + } let mut out = Vec::with_capacity(ks.len()); - for (w, win) in ks.chunks(window).enumerate() { - if !capped() { - out.par_extend(ks[w * window..].par_iter().map(|&k| encode(k, render(k)))); + let mut rest = &ks[..]; + while !rest.is_empty() { + let Some(n) = caps.renders(gpu_live()) else { + out.par_extend(rest.par_iter().map(|&k| encode(k, render(k)))); break; - } + }; + let (win, tail) = rest.split_at(caps.window(n).min(rest.len())); let mut imgs = Vec::with_capacity(win.len()); let mut i = 0; while i < win.len() { - let end = if capped() { (i + n).min(win.len()) } else { win.len() }; + let end = caps.renders(gpu_live()).map_or(win.len(), |n| (i + n).min(win.len())); imgs.par_extend(win[i..end].par_iter().map(|&k| render(k))); i = end; } out.par_extend(win.par_iter().copied().zip(imgs.into_par_iter()).map(|(k, img)| encode(k, img))); + rest = tail; } out } @@ -242,11 +262,11 @@ impl<'a> Cx<'a> { } return out; } - // A GPU cap alone is for the GPU's memory: once it has stopped for good, frames render on - // the CPU in parallel. A CPU cap (#1615) holds throughout. - let (cap, lapses) = inflight_cap(self.gpu_cap(), cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref())); - let live = || !lapses || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, cap, live, |k| self.frame(comp, k), f) + // The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the + // CPU, in parallel or under the CPU cap (#1615), which holds throughout. + let caps = Inflight { gpu: self.gpu_cap(), cpu: cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref()) }; + let gpu_live = || self.job.accel.is_none_or(|a| a.available()); + map_frames_while(ks, caps, gpu_live, |k| self.frame(comp, k), f) } /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every @@ -489,6 +509,7 @@ fn premultiplied8(img: &Image) -> Vec { mod tests { use super::*; use rayon::prelude::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { @@ -521,7 +542,7 @@ mod tests { let out = pool.install(|| { map_frames_while( (0..24).collect(), - Some(1), + Inflight { gpu: Some(1), cpu: None }, || live.load(Ordering::SeqCst), |k| { if k == 1 { @@ -694,16 +715,88 @@ mod tests { #[test] fn cpu_cap_holds_and_combines_with_the_gpu_cap() { // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. - assert_eq!(inflight_cap(None, None), (None, false)); - assert_eq!(inflight_cap(None, Some(2)), (Some(2), false)); - assert_eq!(inflight_cap(Some(1), None), (Some(1), true)); - assert_eq!(inflight_cap(Some(3), Some(2)), (Some(2), false)); - assert_eq!(inflight_cap(Some(1), Some(4)), (Some(1), false)); + let caps = |gpu, cpu| Inflight { gpu, cpu }; + for live in [true, false] { + assert_eq!(caps(None, None).renders(live), None); + assert_eq!(caps(None, Some(2)).renders(live), Some(2)); + } + assert_eq!(caps(Some(1), None).renders(true), Some(1)); + assert_eq!(caps(Some(1), None).renders(false), None); + assert_eq!(caps(Some(3), Some(2)).renders(true), Some(2)); + assert_eq!(caps(Some(1), Some(4)).renders(true), Some(1)); + // Review of #1615: once the GPU stops, the CPU cap applies, not the GPU's 1. + assert_eq!(caps(Some(1), Some(4)).renders(false), Some(4)); // A CPU cap of 2 on 8 threads renders at most 2 frames at once. let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); - let (out, max) = pool.install(|| run_capped(16, inflight_cap(None, Some(2)).0)); - assert_eq!(out.len(), 16); - assert!(max <= 2, "{max} frames in flight"); + let (now, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| map_frames_while((0..16).collect(), caps(None, Some(2)), || true, |k| track(&now, &max, k), |_, k| k)); + assert_eq!(out, (0..16).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "{max:?} frames in flight"); + } + + /// Counts `k` in flight in `now`, records the most in `max`, and holds it for a moment. + fn track(now: &AtomicUsize, max: &AtomicUsize, k: u64) -> u64 { + let c = now.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + now.fetch_sub(1, Ordering::SeqCst); + k + } + + #[test] + fn a_cpu_cap_bounds_the_images_waiting_to_encode() { + // Review of #1615: a window of one image per thread was rendered before any encoded, so + // a cap of 2 on 8 threads still held 8 finished images. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + for gpu in [None, Some(1)] { + let (held, max) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..20).collect(), + Inflight { gpu, cpu: Some(2) }, + || true, + |k| { + let c = held.fetch_add(1, Ordering::SeqCst) + 1; + max.fetch_max(c, Ordering::SeqCst); + k + }, + |k, img| { + held.fetch_sub(1, Ordering::SeqCst); + assert_eq!(k, img); + k + }, + ) + }); + assert_eq!(out, (0..20).collect::>()); + assert!(max.load(Ordering::SeqCst) <= 2, "gpu {gpu:?}: {max:?} images held"); + } + } + + #[test] + fn the_cpu_cap_takes_over_once_the_gpu_stops() { + // Review of #1615: with both caps, frames after the GPU stopped rendered one at a time. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (live, now, max) = (AtomicBool::new(true), AtomicUsize::new(0), AtomicUsize::new(0)); + let out = pool.install(|| { + map_frames_while( + (0..40).collect(), + Inflight { gpu: Some(1), cpu: Some(4) }, + || live.load(Ordering::SeqCst), + |k| { + if k == 1 { + live.store(false, Ordering::SeqCst); + } + if k < 4 { + return k; + } + track(&now, &max, k) + }, + |_, k| k, + ) + }); + assert_eq!(out, (0..40).collect::>()); + let max = max.load(Ordering::SeqCst); + assert!((2..=4).contains(&max), "{max} frames in flight after the GPU stopped"); } #[test] From 79dec68d633059915851aa22d8021311d7c2abc2 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:35:59 +0200 Subject: [PATCH 0137/1386] Marker names: a short duration stays visible beside the name box When a named marker's duration ended inside the name box, the box's fill covered the duration's end cap and most of its bar, so the duration could not be seen. The box now steps past the end cap in that case (and stays at the flag when the bar is longer than the box, or ends before it). The new test puts a 0.1 s duration on a named marker, takes the timeline's scale from a second marker, and checks that the cap and the bar are drawn and that the box starts after the cap; it fails with the previous drawing. The two render tests now take a lock, because two tests creating a GPU device at once crash some Vulkan loaders. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/markers_ui.rs | 14 ++++++-- crates/ui-egui/tests/ui_marker_names.rs | 46 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index ca9a25967..186dfafb7 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -283,8 +283,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: let x = tm.x(comp_t); let col = marker_color(&t, m.label); let h = (y1 - y0).min(10.0); - if m.duration > Tick(0) { - let xe = tm.x(comp_t + m.duration.seconds()); + let end = (m.duration > Tick(0)).then(|| tm.x(comp_t + m.duration.seconds())); + if let Some(xe) = end { let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0)); p.rect_filled(bar, 1.0, col.gamma_multiply(if m.protected { 0.35 } else { 0.5 })); if m.protected { @@ -301,7 +301,15 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field // colours, so it follows Dark, Darker and Light. let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); - let box_rect = Rect::from_min_size(pos2(x + 6.0, y0), vec2(galley.size().x + 8.0, h.max(galley.size().y + 2.0))); + let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0); + // A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead. + if let Some(xe) = end + && xe > box_x + && xe < box_x + box_w + { + box_x = xe + 3.0; + } + let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0))); p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); } diff --git a/crates/ui-egui/tests/ui_marker_names.rs b/crates/ui-egui/tests/ui_marker_names.rs index b2eea20b4..99adf41f3 100644 --- a/crates/ui-egui/tests/ui_marker_names.rs +++ b/crates/ui-egui/tests/ui_marker_names.rs @@ -8,12 +8,16 @@ use effectcraft_ui_egui::theme::ThemeKind; use egui_kittest::Harness; use serde_json::json; +/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders. +static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn close(got: [u8; 4], want: egui::Color32) -> bool { got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) } #[test] fn the_name_box_follows_the_theme() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut s = Session::default(); s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); @@ -49,3 +53,45 @@ fn the_name_box_follows_the_theme() { } assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey"); } + +/// A duration shorter than the name box ends where the box would sit. The box steps past the end cap, so the duration +/// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it. +#[test] +fn a_short_duration_stays_visible_beside_the_name_box() { + let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse", "duration": 0.1})).unwrap(); + // a second marker half a second later gives the timeline's scale (points per second) + s.execute("markers.set", json!({"new": true, "time": 1.5})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(5); + let tokens = h.state().tokens; + let centre = |id: &str| { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is on screen")).rect; + (r[0] + r[2] / 2.0, r[1] + 1.0, (r[3] - 1.0).min(10.0)) + }; + let ((xa, y0, flag_h), (xb, _, _)) = (centre("timeline.marker.0"), centre("timeline.marker.1")); + let per_second = (xb - xa) / 0.5; + let xe = xa + 0.1 * per_second; + assert!(xe - xa < 30.0, "the duration is shorter than the name box: {} points", xe - xa); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join("marker-short-duration.png")).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + flag_h / 2.0; + // the end cap: a pixel in the marker's colour (new markers are red; the theme's field fill is neutral) within a point + // of where the duration ends, which the box would otherwise cover + let cap = (-1..=1).map(|d| at(xe + d as f32, mid)).max_by_key(|p| i32::from(p[0]) - i32::from(p[1])).unwrap(); + assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the end cap is drawn: {cap:?}"); + // the bar shows left of the cap, not the box's fill + let bar = at(xe - 4.0, mid); + assert!(i32::from(bar[0]) - i32::from(bar[1]) > 20, "the bar shows left of the end cap, not the box: {bar:?}"); + // the box starts after the cap + let fill = at(xe + 3.0 + 2.5, mid); + assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg); +} From c1d411b2495f49ead824876339d92d19e06eb030 Mon Sep 17 00:00:00 2001 From: iannitram <4624371+iannitram@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:48:07 -0600 Subject: [PATCH 0138/1386] Tests: serialise disk-cache-opening tests with the roto tests The Roto/Object Matte segment store is one per process (roto::set_seg_store), and every Session::configure_disk_cache that opens a cache takes it over. The roto and disk-cache tests already serialise on hold_roto_cache_test_lock(), but many other engine tests build a Session with a settings store, which opens the disk cache at the platform folder and takes the store; once that session drops, the weak store is dead and object_matte_mattes_persist_in_the_disk_cache sees no disk writes. It failed 3 of 3 in the full parallel suite here. configure_disk_cache now takes that lock under cfg(test) before opening a cache, so every such test runs apart from the roto tests. Re-entry on the same thread is a no-op. Product code is unchanged: only the app's one session ever opens a disk cache, so users cannot hit the race. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session_settings.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ef3e748a6..0b457926c 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -116,6 +116,11 @@ impl Session { self.layer_cache.set_disk(None); return; }; + // Tests: the Roto/Object Matte segment store below is one per process, so a test that + // opens a disk cache (often the platform folder, through a settings store) would take + // over the store of a roto test running beside it. Serialise them with the roto tests. + #[cfg(test)] + crate::tests_roto::hold_roto_cache_test_lock(); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); From 620d340d1577c6470c0685c1cec6607259f69184 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 0139/1386] WAV markers: read RF64 files and bound the adtl list Two findings from the review of #1423. RF64 (WAVs over 4 GB): a chunk too big for 32 bits has a size field of 0xFFFFFFFF and its real size in the file's `ds64` chunk. The reader took the field literally, so it stopped at the `data` chunk and never reached the `cue ` and `_PMX` chunks that follow it. It now resolves such sizes through `ds64` (the `data` size, and a table of other chunks, capped at 1,024 entries), with checked arithmetic, and leaves the field as it was when there is no usable `ds64`. The `adtl` list: every `labl` record scanned the growing list of names, which is quadratic in the number of records, and nothing bounded how many were kept. The cue points are now read first, names and lengths are kept only for those ids in hash maps, and at most 40,000 `adtl` sub-chunks are looked at. The duplicate check between the cue and XMP markers uses a set instead of a scan. What a file yields is unchanged for any file within the bounds: a `labl` still wins over a `note`, and the first `ltxt` still gives the length. Tests: an RF64 file whose markers follow a sentinel-sized `data` chunk, sizes taken from the `ds64` table (and not taken when the table has no entry), hostile `ds64` sizes and tables and every truncation of an RF64 file, and a 100,000-record `adtl` list that is read in linear time, with the cap applied. The RF64 tests fail with the previous reader. Co-Authored-By: Claude Sonnet 5.5 --- crates/media/src/wav_markers.rs | 223 ++++++++++++++++++++++++++++---- 1 file changed, 195 insertions(+), 28 deletions(-) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs index f7c1bb84d..ca40824b6 100644 --- a/crates/media/src/wav_markers.rs +++ b/crates/media/src/wav_markers.rs @@ -7,8 +7,13 @@ //! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames //! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). //! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! //! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and -//! the number of markers and the size of the XMP packet are capped. +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; use effectcraft_color::Label; use effectcraft_project::Marker; @@ -18,12 +23,21 @@ use effectcraft_time::Tick; pub const MAX_MARKERS: usize = 10_000; /// Largest XMP packet read (bytes). const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; fn u32_at(b: &[u8], at: usize) -> Option { let s = b.get(at..at.checked_add(4)?)?; Some(u32::from_le_bytes(s.try_into().ok()?)) } +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + /// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. fn label(b: &[u8]) -> String { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); @@ -39,14 +53,55 @@ pub fn is_wav(bytes: &[u8]) -> bool { matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") } +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + /// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); let mut out = Vec::new(); let mut at = 12usize; while let Some(head) = bytes.get(at..at.saturating_add(8)) { - let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; let start = at.saturating_add(8); - let Some(end) = start.checked_add(size as usize) else { break }; + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; let Some(body) = bytes.get(start..end) else { break }; out.push((id, body)); // Chunks are padded to an even size. @@ -64,9 +119,10 @@ pub fn read(bytes: &[u8]) -> Vec { let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); let mut out = cue_markers(&chunks, rate); if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); for m in xmp_markers(xmp, rate) { - // Tools that write both encodings repeat each marker. - if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + if seen.insert((m.comment.clone(), m.time)) { out.push(m); } } @@ -80,43 +136,57 @@ pub fn read(bytes: &[u8]) -> Vec { fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); - // Cue id -> (name, length in samples). - let mut names: Vec<(u32, String)> = Vec::new(); - let mut lengths: Vec<(u32, u32)> = Vec::new(); - for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { let mut at = 4usize; while let Some(head) = list.get(at..at.saturating_add(8)) { let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; let start = at.saturating_add(8); let Some(end) = start.checked_add(size as usize) else { break }; let Some(body) = list.get(start..end) else { break }; - if let Some(cue_id) = u32_at(body, 0) { + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { match &id { - b"labl" | b"note" => { - let text = label(body.get(4..).unwrap_or_default()); - // A `labl` is the name; a `note` only when there is none. - let known = names.iter().any(|(c, _)| *c == cue_id); - if &id == b"labl" || !known { - names.retain(|(c, _)| *c != cue_id); - names.push((cue_id, text)); - } + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); } - b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), _ => {} } } at = end.saturating_add(end & 1); } } - let mut out = Vec::new(); - for i in 0..count { - let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; - let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; - let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); - let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); - out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); - } - out + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() } /// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element @@ -347,4 +417,101 @@ mod tests { let m = read(&wav(&[cue(&[(1, 44100)])])); assert_eq!(m[0].time, Tick::ZERO); } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } } From 0c70ee026b0f960e17ce0fa16d19f59763f1d99c Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 0140/1386] WAV markers: the host test covers the bound layers parameter and undo/redo The end-to-end test called Update Markers From Source only through the selection. It now also passes the layer as the bound `layers` parameter (what a script or the menu binding supplies), checks the markers by name, and checks that the update is one undo step: undo removes the restored marker again and redo brings it back. Co-Authored-By: Claude Sonnet 5.5 --- crates/host/tests/wav_markers.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs index 6e3943915..6f88b9c9c 100644 --- a/crates/host/tests/wav_markers.rs +++ b/crates/host/tests/wav_markers.rs @@ -1,9 +1,17 @@ //! Markers a WAV file carries, through the fully wired session (media import included): the -//! layer made from the file starts with them, and Update Markers From Source copies them again. +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. +use effectcraft_engine::Session; use effectcraft_project::LayerSource; use serde_json::json; +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + /// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). fn marker_wav() -> Vec { fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { @@ -67,9 +75,18 @@ fn a_wav_layer_starts_with_the_markers_in_its_file() { list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); - assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); - let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); - assert_eq!(list.as_array().unwrap().len(), 2); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); let _ = std::fs::remove_dir_all(&dir); } From 7999754a139dfdcfb575dab9e9a92d0a09960638 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 03:20:50 +0400 Subject: [PATCH 0141/1386] i18n(ka): Georgian rows for the panel strings main added; drop one it no longer uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy, Paste, Copy Source Path, Guide Layer and Layer Styles join GEORGIAN in the Chinese order; Edit Value…, Remove Expression and Effects move there from GEORGIAN_PANELS now that the Chinese catalogs carry them; Freeze on Last Frame is only a menu command now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ui.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 935973125..c42a17ff9 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -4574,13 +4574,19 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Drag to slip the source", "გადაათრიეთ წყაროს გადასაწევად"), ("Drop Frame", "კადრების გამოტოვებით"), ("Duplicate", "დუბლირება"), + ("Copy", "კოპირება"), + ("Paste", "ჩასმა"), + ("Edit Value…", "მნიშვნელობის რედაქტირება…"), + ("Remove Expression", "ექსპრესიის მოცილება"), ("Duration:", "ხანგრძლივობა:"), ("Edit Gradient…", "გრადიენტის რედაქტირება…"), ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), + ("Effects", "ეფექტები"), ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), + ("Copy Source Path", "წყაროს მისამართის კოპირება"), ("Effects: {}", "ეფექტები: {}"), ("Empty folder", "ცარიელი საქაღალდე"), ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), @@ -4615,6 +4621,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Grab bag", "სხვადასხვა"), ("Gradient Editor", "გრადიენტის რედაქტორი"), ("Grid", "ბადე"), + ("Guide Layer", "დამხმარე შრე"), ("Guide Layers: {}", "დამხმარე შრეები: {}"), ("Guides", "დამხმარე ხაზები"), ("Height:", "სიმაღლე:"), @@ -4643,6 +4650,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Layer {}", "შრე {}"), ("Layer:", "შრე:"), ("Layer: {}", "შრე: {}"), + ("Layer Styles", "შრის სტილები"), ("Learn", "სწავლა"), ("Leave all attributes in \"{}\"", "ყველა ატრიბუტის დატოვება „{}“-ში"), ("Licence: {}", "ლიცენზია: {}"), @@ -5386,12 +5394,8 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("Click in the Composition panel to sample text {}", "დაწკაპეთ კომპოზიციის პანელში ტექსტის ნიმუშის ასაღებად {}"), ("Video preview off: audio only", "ვიდეოს წინასწარი ნახვა გამორთულია: მხოლოდ აუდიო"), ("Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), - ("Effects", "ეფექტები"), ("Video preview is off (Preview panel): audio only", "ვიდეოს წინასწარი ნახვა გამორთულია (წინასწარი ნახვის პანელი): მხოლოდ აუდიო"), ("Solid…", "სოლიდი…"), - ("Freeze on Last Frame", "ბოლო კადრზე გაყინვა"), - ("Edit Value…", "მნიშვნელობის რედაქტირება…"), - ("Remove Expression", "ექსპრესიის მოცილება"), ]; #[cfg(test)] From a5f096324db28ae9ee617616f9528db517c10c92 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 19 Oct 2026 10:36:16 +0900 Subject: [PATCH 0142/1386] Text: draw Faux Bold and synthetic bold (#1676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout marks a glyph synth_bold when Faux Bold is on, or when a bold style is asked of a family without a bold face, but the renderer never read it, so both drew as regular. PlacedGlyph now carries the bold radius (0.03 em, which brings Inter Regular's stems to Inter Bold's). A bold fill is the outline's coverage merged with a round stroke twice that wide, and a bold glyph's stroke is the band between the outline grown by radius ± half the stroke width, so it follows the bolder shape. Runs of solid fills only merge glyphs of the same bold radius, and the patch and layer bounds grow by it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/text.rs | 68 ++++++++++++++++++++++++++------- crates/render/src/text/tests.rs | 31 +++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..856b2f578 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3}; use effectcraft_keyframe::{Justify, TextDoc, Value}; use effectcraft_path::{BezPath, FillRule, StrokeStyle}; use effectcraft_project::{Layer, PropGroup}; -use effectcraft_raster::Image; +use effectcraft_raster::{Image, Mask}; use effectcraft_text::path_text::{self, PathMeasure}; use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly}; use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc}; @@ -479,6 +479,9 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows + /// on every side, in character space (0 when not bold). + pub bold: f64, /// The character's style fills it (Character panel fill on). pub apply_fill: bool, /// Index of the character in the text. @@ -498,8 +501,17 @@ impl PlacedGlyph { pub fn path(&self) -> BezPath { effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0) } + /// [`Self::bold`] in layer space. + fn bold_in_layer(&self) -> f64 { + self.bold * self.m2().mean_scale() + } } +/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter +/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a +/// missing bold face. +const FAUX_BOLD_RADIUS: f64 = 0.03; + /// Laid-out, animated text of a layer at the context time. pub struct TextGeom { pub doc: TextDoc, @@ -777,6 +789,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, rest, @@ -826,17 +839,41 @@ fn passes(geom: &TextGeom) -> Vec { v } +/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill +/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased. +fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask { + let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero); + if d.abs() < 1e-9 { + return cov; + } + let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() }; + let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h); + for (c, r) in cov.data.iter_mut().zip(&ring.data) { + *c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) }; + } + cov +} + /// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths /// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred -/// when the character has Blur. -fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { +/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space. +fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image { let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32); let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m; - let cov = if stroke { + let cov = if stroke && bold > 0.0 { + // The stroke of the bold outline: the band between it grown and shrunk by half the width. + let half = g.stroke_width / 2.0; + let mut outer = grown_coverage(paths, &mm, w, h, bold + half); + let inner = grown_coverage(paths, &mm, w, h, bold - half); + for (o, i) in outer.data.iter_mut().zip(&inner.data) { + *o = o.min(1.0 - i); + } + outer + } else if stroke { let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { - effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero) + grown_coverage(paths, &mm, w, h, bold) }; let col = if stroke { g.stroke } else { g.fill }; let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; @@ -895,7 +932,10 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { if geom.blend == BlendMode::Normal && solid_fill(g, stroke) && let Some((run, false)) = out.last_mut() - && run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill) + && run.last().is_some_and(|&p| { + let q = &geom.glyphs[p]; + solid_fill(q, false) && q.fill == g.fill && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 + }) { run.push(gi); continue; @@ -917,17 +957,17 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt // One glyph is drawn from its own outline; a run from its glyphs' outlines in layer // space, all wound the same way so overlaps add up instead of cancelling (NonZero). let local = Cow::Borrowed(std::slice::from_ref(&g.local)); - let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) { - (Some(_), _) => (local, *to_px), - (None, [_]) => (local, *to_px * g.m2()), + let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) { + (Some(_), _) => (local, *to_px, g.bold), + (None, [_]) => (local, *to_px * g.m2(), g.bold), (None, _) => { let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p }; - (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px) + (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer()) } }; let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; - let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + let pad = (g.stroke_width + 2.0 * bold) * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); let rect = [ ((r.x0 - pad).floor() as i64).max(0), @@ -938,7 +978,7 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; } - let patch = raster_pass(g, stroke, &paths, &m, rect, blur); + let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold); blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend }); } } @@ -950,7 +990,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { let mut bounds: Option = None; for g in &geom.glyphs { if let Some(b) = effectcraft_path::bounds(&[g.path()]) { - let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let pad = g.stroke_width + 2.0 * g.bold_in_layer() + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; let b = b.inflate(pad, pad); bounds = Some(bounds.map_or(b, |a| a.union(b))); } @@ -976,7 +1016,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> continue; } let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue }; - let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; let b = b.inflate(pad, pad); let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096); let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..3e6800aba 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -524,6 +524,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + bold: 0.0, apply_fill: true, char_index: 0, rest: [0.0; 2], @@ -551,6 +552,36 @@ fn touching_glyphs_join_without_a_seam() { assert!(multiply[3] < 0.9, "{multiply:?}"); } +/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn. +#[test] +fn faux_bold_and_synthetic_bold_thicken_the_glyphs() { + // Total alpha and the alpha bounding box of a rendered word. + let ink = |d: TextDoc| { + let (mut p, cid, comp) = setup(400, 120); + let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d }); + set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + (img.data.iter().map(|px| px[3]).sum::(), alpha_bbox(&img).unwrap()) + }; + let (plain, pb) = ink(TextDoc::default()); + let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() }); + assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}"); + // 0.03 em = 1.8 px on every side. + let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]); + assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}"); + // Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold. + let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() }); + let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() }); + assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}"); + // A stroke follows the bold outline: as wide as before, moved out with it. + let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() }; + let (ring, rb) = ink(stroked.clone()); + let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked }); + assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}"); + assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}"); +} + /// #1011: what dragging a Range Selector bar to fraction `f` of the text writes. #[test] fn range_bar_drag_values() { From d3fd2c58d7a3e0045575c6dfdbaa92f1552cd673 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Mon, 19 Oct 2026 13:04:16 +1100 Subject: [PATCH 0143/1386] Key browser preview caches on a file generation, and hash bytes only when that key misses. Replacing a virtual file bumps a counter the UI lookup reads. The content hash runs on that miss, from a callback, so the frame path does not stat every footage item or hash on every store write. --- apps/effectcraft-web/src/app.rs | 1 + apps/effectcraft-web/src/frames.rs | 1 + apps/effectcraft-web/src/store.rs | 61 ++++++++++++++++++++++++-- crates/engine/src/remote.rs | 11 ++++- crates/render/src/disk_cache.rs | 60 ++++++++++++++++++++++---- crates/ui-egui/src/frames.rs | 69 +++++++++++++++++++++++------- 6 files changed, 175 insertions(+), 28 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..68fd1a40c 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -257,6 +257,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/frames.rs b/apps/effectcraft-web/src/frames.rs index 523fd03c8..c7f350526 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -416,6 +416,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/store.rs b/apps/effectcraft-web/src/store.rs index b3a858def..a44a2eac7 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,14 +152,31 @@ 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 { @@ -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/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 207367b07..7d592abe2 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -263,6 +263,8 @@ pub struct FrameServer { /// The disk cache's layer buffers (see the module docs) and the project's salt. layer_store: Option>, salt: u64, + /// Size and content hash for footage paths with no native metadata (the browser file table). + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, } /// A frame whose readbacks are in flight. @@ -294,9 +296,16 @@ impl FrameServer { pending: None, layer_store: None, salt: 0, + virtual_id: None, } } + /// Identity of virtual footage files, used when native metadata cannot describe the path. + /// Called from [`FrameServer::load`], not from the UI thread's cache lookup. + pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { + self.virtual_id = id; + } + /// The store behind the layer cache when a request asks for `layers` (the browser: the /// disk cache's layer buffers, fetched before the request). pub fn set_layer_store(&mut self, store: Option>) { @@ -338,7 +347,7 @@ impl FrameServer { fn load(&mut self, revision: u64) -> Result<(), String> { let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; - self.salt = effectcraft_render::disk_cache::footage_salt(&p); + self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref()); self.cache.set_store_salt(self.salt); self.project = Some(Arc::new(p)); self.revision = Some(revision); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 4140b7923..f867a614a 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(&[0xff]); } -/// A file's size and modification time (so edited footage gets new keys). -fn file_stamp(h: &mut Hash128, path: &str) { +/// Size and content hash of a path that has no native metadata (the browser file table). +pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync); + +/// A file's size and modification time (so edited footage gets new keys). When the path has no +/// native metadata, `virtual_id` supplies a durable size and content hash instead. +fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) { if path.is_empty() { return; } @@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) { { h.write_u64(d.as_nanos() as u64); } + return; + } + if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) { + h.write_u64(len); + h.write_u64(hash); } } -fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) { - file_stamp(h, &f.path); +fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) { + file_stamp(h, &f.path, virtual_id); for p in &f.sequence { - file_stamp(h, p); + file_stamp(h, p, virtual_id); } } /// Salt for layer cache keys on disk: the renderer version and every footage file's identity /// (layer keys name footage items, not file contents). pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { + footage_salt_with(project, None) +} + +/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); for (id, it) in &project.items { if let effectcraft_project::ItemKind::Footage(f) = &it.kind { h.write_u64(id.0); hash_debug(&mut h, f); - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } } let v = h.finish(); @@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { /// files' size and time). With expressions in play (which can read any comp) the whole project /// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { + comp_content_key_with(project, comp, None) +} + +/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); @@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } hash_debug(&mut h, &it.proxy); if let Some(px) = &it.proxy { - footage_stamp(&mut h, &px.footage); + footage_stamp(&mut h, &px.footage, virtual_id); } } h.write_u64(comp.0); @@ -1082,4 +1101,29 @@ mod tests { assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); } } + + /// A path with no native metadata takes its key from the caller's size and content hash (#1253). + #[test] + fn virtual_file_bytes_change_content_keys() { + use effectcraft_project::build; + use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut p = Project::default(); + let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; + let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + + let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); + let (one, two) = (id(1), id(2)); + let a = footage_salt_with(&p, Some(&one)); + let b = footage_salt_with(&p, Some(&two)); + let ka = comp_content_key_with(&p, cid, Some(&one)); + let kb = comp_content_key_with(&p, cid, Some(&two)); + assert_ne!(a, b, "replaced virtual media must not reuse the salt"); + assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key"); + assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None)); + } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..a0a893452 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -24,7 +24,7 @@ //! them (Info panel, eyedroppers, histograms). use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; @@ -258,8 +258,10 @@ pub struct RenderSource { /// ([`Frames::identity`]). type Identity = (u64, Option>); -/// Content keys of (project revision, comp), computed once per revision. -type ContentKeys = Arc>>; +/// Content keys of (project revision, comp, virtual-file generation). The generation is one +/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the +/// new bytes happens only when that generation misses. +type ContentKeys = Arc>>; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] @@ -268,16 +270,16 @@ struct ContentKeyJobs { pending: VecDeque<(Arc, u64, u64)>, } -fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { - if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option>) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) { return k; } - let k = disk_cache::comp_content_key(project, ItemId(comp)); + let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id); if let Ok(mut m) = keys.lock() { if m.len() > 256 { m.clear(); } - m.insert((revision, comp), k); + m.insert((revision, comp, epoch), k); } k } @@ -363,8 +365,15 @@ pub fn opts_hash(o: &RenderOpts) -> u64 { } /// Disk key of a viewer frame (render options included: region of interest, draft…). -fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { - let content = content_key(keys, project, key.revision, key.comp); +fn frame_disk_key( + keys: &ContentKeys, + project: &Project, + key: &FrameKey, + opts_hash: u64, + epoch: u64, + virtual_id: Option>, +) -> u128 { + let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id); disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } @@ -441,6 +450,10 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + /// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage. + virtual_epoch: Arc, + /// Size and content hash for paths with no native metadata. Called only on a cache miss. + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc>, gpu_retired: Arc, @@ -477,6 +490,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + virtual_epoch: Arc::new(AtomicU64::new(0)), + virtual_id: None, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc::default(), gpu_retired: Arc::default(), @@ -768,9 +783,10 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } + let epoch = self.virtual_epoch.load(Ordering::Relaxed); #[cfg(not(target_arch = "wasm32"))] let content = { - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return vec![]; @@ -778,7 +794,7 @@ impl Frames { content }; #[cfg(target_arch = "wasm32")] - let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref()); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (0..frames.clamp(0, 100_000)) @@ -803,6 +819,7 @@ impl Frames { jobs.running = true; drop(jobs); let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); + let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone()); let work = move || loop { let next = { let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -813,7 +830,9 @@ impl Frames { next }; let Some((project, revision, comp)) = next else { return }; - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { + let epoch = epoch.load(Ordering::Relaxed); + let id = id.clone(); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() { log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); } if let Some(ctx) = &ctx { @@ -964,10 +983,22 @@ impl Frames { ctx: self.ctx.clone(), last: self.last_ms.clone(), content_keys: self.content_keys.clone(), + virtual_epoch: self.virtual_epoch.clone(), + virtual_id: self.virtual_id.clone(), gpu_retired: self.gpu_retired.clone(), } } + /// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content + /// hash used when a content key is rebuilt. Neither runs on a cache hit. + pub fn set_virtual_media(&mut self, epoch: Arc, id: Arc Option<(u64, u64)> + Send + Sync>) { + self.virtual_epoch = epoch; + self.virtual_id = Some(id); + if let Ok(mut keys) = self.content_keys.lock() { + keys.clear(); + } + } + /// Render frames with `remote` from now on (`None`: on this thread again). pub fn set_remote(&mut self, remote: Option>) { self.remote = remote; @@ -1036,7 +1067,8 @@ impl Frames { let fw = self.worker(); let busy = self.remote_busy.clone(); let t0 = web_time::Instant::now(); - let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts))); + let epoch = w.virtual_epoch.load(Ordering::Relaxed); + let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref())); let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key }; remote.start( rj, @@ -1093,6 +1125,8 @@ struct Worker { ctx: Option, last: Arc>, content_keys: ContentKeys, + virtual_epoch: Arc, + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, gpu_retired: Arc, } @@ -1147,7 +1181,12 @@ impl Worker { job }; let t0 = web_time::Instant::now(); - let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)))); + let epoch = self.virtual_epoch.load(Ordering::Relaxed); + let disk = job + .src + .disk + .as_ref() + .map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref()))); let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k)); let disk_hit = from_disk.is_some(); let ci = match from_disk { @@ -1428,7 +1467,7 @@ mod tests { assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); release.send(()).unwrap(); let until = Instant::now() + Duration::from_secs(3); - while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } From 4bc40c7ac3d7c37d4fb113b335afd8cffaca2c7a Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 19 Oct 2026 11:04:36 +0900 Subject: [PATCH 0144/1386] Text: grow Faux Bold per axis under a non-uniform scale (#1676) Synthetic bold grows a glyph's outline in its own space, so a non-uniform character Scale stretches it more along one axis. The patch and layer bounds reserved it through mean_scale and could clip the stretched side; they now reserve how far the bold reaches along x and y. A run of solid fills grows its glyphs together in layer space by one radius, which only matches when the bold stays round there: a bold glyph whose matrix stretches or skews is no longer gathered into a run. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/text.rs | 36 +++++++++++++++++++++++++-------- crates/render/src/text/tests.rs | 28 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 856b2f578..f74cf7c8f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -505,6 +505,19 @@ impl PlacedGlyph { fn bold_in_layer(&self) -> f64 { self.bold * self.m2().mean_scale() } + /// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space: + /// no bold, or a matrix that only rotates and scales uniformly. + fn bold_is_even(&self) -> bool { + let [[a, b, _], [c, d, _], _] = self.m2().0; + let n = a * a + b * b + c * c + d * d; + self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n) + } +} + +/// How far a circle of radius `r` reaches along x and along y once mapped by `m`. +fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] { + let [[a, b, _], [c, d, _], _] = m.0; + [r * a.hypot(b), r * c.hypot(d)] } /// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter @@ -929,12 +942,15 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { if g.xf.opacity <= 0.0 { continue; } + // A run grows its outline in layer space by one radius, which matches each glyph grown in + // its own space only when the bold stays round there. if geom.blend == BlendMode::Normal && solid_fill(g, stroke) + && g.bold_is_even() && let Some((run, false)) = out.last_mut() && run.last().is_some_and(|&p| { let q = &geom.glyphs[p]; - solid_fill(q, false) && q.fill == g.fill && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 + solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 }) { run.push(gi); @@ -967,13 +983,16 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt }; let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; - let pad = (g.stroke_width + 2.0 * bold) * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + // Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach + // further along one axis than the other. + let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold); + let pad = 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); let rect = [ - ((r.x0 - pad).floor() as i64).max(0), - ((r.y0 - pad).floor() as i64).max(0), - ((r.x1 + pad).ceil() as i64).min(w), - ((r.y1 + pad).ceil() as i64).min(h), + ((r.x0 - px - pad).floor() as i64).max(0), + ((r.y0 - py - pad).floor() as i64).max(0), + ((r.x1 + px + pad).ceil() as i64).min(w), + ((r.y1 + py + pad).ceil() as i64).min(h), ]; if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; @@ -990,8 +1009,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { let mut bounds: Option = None; for g in &geom.glyphs { if let Some(b) = effectcraft_path::bounds(&[g.path()]) { - let pad = g.stroke_width + 2.0 * g.bold_in_layer() + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; - let b = b.inflate(pad, pad); + let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold); + let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let b = b.inflate(pad + bx, pad + by); bounds = Some(bounds.map_or(b, |a| a.union(b))); } } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 3e6800aba..4b1b66809 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -582,6 +582,34 @@ fn faux_bold_and_synthetic_bold_thicken_the_glyphs() { assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}"); } +/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it +/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run. +#[test] +fn faux_bold_under_a_non_uniform_scale() { + use kurbo::Shape; + let glyph = |x0: f64| PlacedGlyph { + local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1), + m: Mat4::scale(vec3(9.0, 1.0, 1.0)), + xf: CharXf::default(), + fill: [1.0; 4], + stroke: [0.0; 4], + stroke_width: 0.0, + bold: 2.0, + apply_fill: true, + char_index: 0, + rest: [0.0; 2], + }; + let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal }; + assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one"); + // x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18. + let mut img = crate::Image::new(120, 24); + draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None); + for (x, y) in [(37, 10), (106, 10), (70, 3)] { + assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y)); + } + assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01); +} + /// #1011: what dragging a Range Selector bar to fraction `f` of the text writes. #[test] fn range_bar_drag_values() { From 76098665b0d78f3f42f22275b1b8e8bd8442baf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Mon, 19 Oct 2026 05:36:34 +0200 Subject: [PATCH 0145/1386] Cryptomatte: effect.pickCryptomatte picks the ID under a point into the Selection (#473) Engine part of viewer picking: replace / add / remove the top-coverage ID at a layer-space point in one undo step, at the time the Selection was read. The aux channels come from the render's own path (Renderer::layer_aux, shared with the effect host; footage at its source time, 3D precomps) through a renderer set up like Session::render (expressions, render prefs, layer cache). An optional scale snaps the point to the pixel a preview at that resolution samples. New effects::crypto_select quotes names and falls back to for unnamed, wildcard-looking or ambiguous names. effect.cryptomatteAt is the read-only hover query. --- crates/effects/src/channel3d.rs | 2 +- crates/effects/src/crypto_select.rs | 122 +++++++++++++++++ crates/effects/src/lib.rs | 1 + crates/engine/src/commands/effect.rs | 100 ++++++++++++++ crates/engine/src/lib.rs | 10 +- crates/engine/src/tests_crypto_pick.rs | 181 +++++++++++++++++++++++++ crates/host/tests/cryptomatte_pick.rs | 127 +++++++++++++++++ crates/raster/src/channels3d.rs | 47 +++++++ crates/render/src/lib.rs | 49 ++++--- docs/agents.md | 5 +- 10 files changed, 620 insertions(+), 24 deletions(-) create mode 100644 crates/effects/src/crypto_select.rs create mode 100644 crates/engine/src/tests_crypto_pick.rs create mode 100644 crates/host/tests/cryptomatte_pick.rs diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7884e8084..7c4f0e1df 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -326,7 +326,7 @@ fn wild(pat: &str, name: &str) -> bool { /// Split a selection at its commas. A name in double or single quotes stays whole, commas /// included (Blender allows `Green, Glossy`), and a backslash takes the next character /// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped. -fn selection_names(sel: &str) -> Vec { +pub(crate) fn selection_names(sel: &str) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut quote: Option = None; diff --git a/crates/effects/src/crypto_select.rs b/crates/effects/src/crypto_select.rs new file mode 100644 index 000000000..19d93cdba --- /dev/null +++ b/crates/effects/src/crypto_select.rs @@ -0,0 +1,122 @@ +//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it +//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads. + +use crate::channel3d::{selection_hashes, selection_names}; + +/// How a pick changes the selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickMode { + Replace, + Add, + Remove, +} + +/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a +/// backslash, which the selection parser would otherwise split on or read as syntax. +fn quote(name: &str) -> String { + if !name.contains([',', '"', '\'', '\\']) { + return name.to_string(); + } + let mut out = String::from("\""); + for c in name.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it +/// reads back as exactly that ID, else ``. That covers unnamed IDs, names with `*` (which +/// would select by wildcard), names that look like ``, names with surrounding spaces (the +/// parser trims them) and names shared by several IDs. +pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String { + match name { + Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n), + _ => format!("<{hash:08x}>"), + } +} + +/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text +/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just +/// that ID, Add when it already selects it, Remove when no name or `` token selects it (a +/// wildcard that matches it stays). Other tokens (wildcards included) keep their text, +/// re-quoted where needed, separated by `, `. +pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String { + let tokens: Vec = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect(); + let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash); + let kept: Vec = match mode { + PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(), + PickMode::Replace => return token.to_string(), + PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(), + PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(), + PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(), + PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(), + }; + kept.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_raster::channels3d::crypto_hash; + + fn manifest() -> Vec<(String, u32)> { + ["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"] + .iter() + .map(|n| (n.to_string(), crypto_hash(n))) + .chain([("twin".to_string(), 7)]) + .collect() + } + + /// Every name's token reads back as exactly its ID, through the effect's own parser. + #[test] + fn tokens_round_trip_through_selection_hashes() { + let m = manifest(); + for (n, h) in &m { + let t = token_for(Some(n), *h, Some(&m)); + assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}"); + } + assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny"); + assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#); + assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#); + // Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash. + assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*"))); + assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>"))); + assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>"); + assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad "))); + assert_eq!(token_for(None, 0xdead_beef, None), ""); + assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin"); + } + + #[test] + fn add_and_remove_keep_other_tokens_and_skip_duplicates() { + let m = manifest(); + let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy")); + let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m)); + let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m)); + assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny"); + assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged"); + assert_eq!(edit("", "bunny", PickMode::Add), "bunny"); + assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#); + // Already selected: by name, by hash, by a wildcard. The text stays as typed. + assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor"); + assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>")); + assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*"); + // Other tokens keep their meaning: quoted where needed, wildcards stay wildcards. + assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#); + assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#); + assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x"); + assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), ""); + // Not selected, or only by a wildcard: unchanged. + assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b"); + assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x"); + for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] { + let before = selection_hashes(sel, Some(&m)); + let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m)); + assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}"); + } + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..773bef07c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,6 +21,7 @@ mod color2; mod color3; mod color_fx; pub mod controls; +pub mod crypto_select; mod distort; mod distort2; mod distort3; diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..fca4baa5b 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -186,6 +186,91 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "color": color})) } +/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its +/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. +struct CryptoHit { + at: (effectcraft_project::ItemId, LayerId, Tick), + aux: std::sync::Arc, + layer: String, + hash: u32, + selection: String, + prop: Uid, + /// The Selection has an expression: setting its value wouldn't change what it reads. + driven: bool, +} + +impl CryptoHit { + fn manifest(&self) -> Option<&[(String, u32)]> { + self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice()) + } + fn reply(&self, sel: &str) -> Value { + let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash); + let name = self.aux.manifest_name(&self.layer, self.hash); + json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected}) + } +} + +/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for +/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point +/// snaps to the centre of the pixel a render at `scale` (the viewer's resolution) samples. +fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { + let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let scale = f_p(p, "scale").unwrap_or(1.0); + if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) { + return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == fx_uid)).ok_or_else(|| bad(cmd, "no such effect"))?; + let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; + let t = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); + let aux = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).layer_aux(&ctx, layer); + let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); + let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { + return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); + }; + let snap = |v: f64| ((v * scale).floor() + 0.5) / scale; + let Some(hash) = aux.index_at(snap(x), snap(y)).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; + let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); + Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) +} + +/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. +fn crypto_at(s: &mut Session, p: &Value) -> Result { + Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) +} + +/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it +/// or remove it, in one undo step at the time it was read (none when its meaning stays). +fn pick_crypto(s: &mut Session, p: &Value) -> Result { + use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for}; + let cmd = "effect.pickCryptomatte"; + let mode = match str_p(p, "mode").unwrap_or("replace") { + "replace" => PickMode::Replace, + "add" => PickMode::Add, + "remove" => PickMode::Remove, + m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))), + }; + let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?; + if h.driven { + return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); + } + let m = h.manifest(); + let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m); + let changed = sel != h.selection; + if changed { + let (cid, lid, t) = h.at; + s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?; + } + let mut out = h.reply(&sel); + out["selection"] = json!(sel); + out["changed"] = json!(changed); + Ok(out) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cid = super::comp_id(s, p)?; let fx = effects_p(s, p, "effect.remove")?; @@ -632,6 +717,21 @@ pub fn specs() -> Vec { has_layers, pick_color ), + cmd!( + "effect.pickCryptomatte", + "Pick Cryptomatte", + [], + None, + "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point", + has_layers, + pick_crypto + ), + crate::query!( + "effect.cryptomatteAt", + "Cryptomatte At", + "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point", + crypto_at + ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all), cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..8fa4483f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1036,12 +1036,18 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + self.renderer(opts).comp_frame(comp, t) + } + + /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and + /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. + pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.layer_cache); r.accel = self.accel.as_deref(); - r.comp_frame(comp, t) + r } /// Resolve a comp reference (item id number or name; `None`/null = the active comp). @@ -1242,6 +1248,8 @@ mod tests_codec_options; #[cfg(test)] mod tests_color_view; #[cfg(test)] +mod tests_crypto_pick; +#[cfg(test)] mod tests_delete_keys; #[cfg(test)] mod tests_disk_cache; diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs new file mode 100644 index 000000000..4fdbcbf41 --- /dev/null +++ b/crates/engine/src/tests_crypto_pick.rs @@ -0,0 +1,181 @@ +//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the +//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated +//! pass) and edits the Selection text in one undo step. + +use std::sync::Arc; + +use effectcraft_project::{Footage, FootageKind, ItemId}; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::FootageSource; + +const COMMA: &str = "Green, \"Glossy\""; + +/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x: +/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of +/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and +/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1. +struct Crypto; + +fn aux_at(t: Tick) -> AuxChannels { + let (w, h) = (16usize, 8usize); + let mut planes = vec![vec![0.0f32; w * h]; 4]; + for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) { + let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" }; + let (a, ca, b, cb) = match x { + 0..4 => (late, 0.6, "right", 0.4), + 4..8 => ("left", 0.3, COMMA, 0.7), + 8..12 => ("star*", 1.0, "", 0.0), + 12..14 => ("ghost", 1.0, "", 0.0), + _ => ("left", 0.5, "right", 0.5), + }; + for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() { + planes[k][i] = v; + } + } + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + for (k, c) in ["R", "G", "B", "A"].iter().enumerate() { + aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone())); + } + aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + aux +} + +impl FootageSource for Crypto { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(8, 4))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + let duration = Tick::from_seconds_f64(2.0); + Ok(Footage { + path: path.into(), + kind: FootageKind::Video, + width: 8, + height: 4, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration, + ..Default::default() + }) + } +} + +fn session() -> Session { + let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s +} + +fn pick(s: &mut Session, x: f64, mode: &str) -> Value { + s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap() +} + +fn selection(s: &Session) -> String { + let l = &s.active_comp().unwrap().layers[0]; + let g = l.effects().unwrap().groups().next().unwrap(); + match &g.prop("selection").unwrap().value { + effectcraft_keyframe::Value::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +#[test] +fn picks_replace_add_and_remove_in_one_undo_step_each() { + let mut s = session(); + let steps = s.history.undo.len(); + let r = pick(&mut s, 0.5, "replace"); + assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}"); + assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left"))); + assert_eq!(s.history.undo.len(), steps + 1); + // The highest coverage wins even when it is not rank 0; the name is quoted. + assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#); + // Adding it again changes nothing and leaves no undo step. + let again = pick(&mut s, 3.5, "add"); + assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2)); + // A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0. + pick(&mut s, 4.5, "add"); + assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left"); + let r = pick(&mut s, 0.0, "remove"); + assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(r["selected"], false); + // The text parses back to exactly the picked IDs. + let manifest = aux_at(Tick::ZERO).manifests[0].1.clone(); + let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest)); + assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash)); + assert_eq!(s.history.undo.len(), steps + 5); + s.execute("edit.undo", json!({})).unwrap(); + assert!(selection(&s).starts_with("left, "), "{}", selection(&s)); + pick(&mut s, 2.0, "replace"); + assert_eq!(selection(&s), r#""Green, \"Glossy\"""#); + // The same selection with other spacing: no change, no undo step. + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap(); + let n = s.history.undo.len(); + assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n)); +} + +#[test] +fn bad_points_layers_and_modes_are_errors_and_change_nothing() { + let mut s = session(); + let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y})); + for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] { + assert!(at(&mut s, x, y).is_err(), "{x},{y}"); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null); + } + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err()); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err()); + for scale in [0.0, -1.0, 2.0] { + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}"); + } + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect"); + assert_eq!(selection(&s), ""); + // A layer without aux channels. + s.execute("layer.newSolid", json!({"name": "S"})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string(); + assert!(e.contains("no Cryptomatte channels"), "{e}"); + // An expression-driven Selection would ignore the edit. + let mut s = session(); + s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err()); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false); +} + +/// The pick reads the frame the render shows: Time Remap's source time, not the layer time. +#[test] +fn time_remap_picks_the_shown_source_frame() { + let mut s = session(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left"); + s.execute_checked("layer.enableTimeRemap", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late"); +} + +/// A precomp of a 3D comp: the generated CryptoObject pass names its layers. +#[test] +fn picks_on_a_precomp_of_a_3d_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); + assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); +} diff --git a/crates/host/tests/cryptomatte_pick.rs b/crates/host/tests/cryptomatte_pick.rs new file mode 100644 index 000000000..69092ce95 --- /dev/null +++ b/crates/host/tests/cryptomatte_pick.rs @@ -0,0 +1,127 @@ +//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the +//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows, +//! including undo. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::json; + +const RIGHT: &str = "Right, \"Box\""; + +/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and +/// the comma-and-quotes name from there, both named in its manifest. +fn crypto_exr(tag: &str, split: usize) -> String { + use exr::prelude::*; + let (w, h) = (8usize, 4usize); + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string(); + for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + // A folder of its own: numbered siblings would import as an image sequence. + let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("crypto.exr"); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect. +fn footage(tag: &str, split: usize) -> (Session, String) { + let path = crypto_exr(tag, split); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + (s, path) +} + +/// The matte's alpha over the left and right halves. +fn matte(s: &Session) -> [f32; 2] { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + [img.get(1, 1)[3], img.get(6, 2)[3]] +} + +#[test] +fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() { + let (mut s, path) = footage("edit", 4); + s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap(); + assert_eq!(matte(&s), [0.0, 0.0], "nothing selected"); + let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap(); + + assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left"); + assert_eq!(matte(&s), [1.0, 0.0]); + let r = pick(&mut s, 6.0, "add"); + assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}"); + assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#); + assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half"); + pick(&mut s, 2.0, "remove"); + assert_eq!(matte(&s), [0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 0.0]); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview +/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up. +#[test] +fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() { + let (mut s, path) = footage("half", 3); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap(); + assert_eq!(r["name"], RIGHT, "{r}"); + let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() }; + assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0); + // At full resolution the same point is Left's pixel. + let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap(); + assert_eq!(r["name"], "Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A keyframed Selection picked at `time` is edited at that time, not at the current time. +#[test] +fn a_keyframed_selection_is_edited_where_it_was_read() { + let (mut s, path) = footage("key", 4); + for t in [0.0, 1.0] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap(); + } + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap(); + assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}"); + let sel = |s: &mut Session, t: f64| { + let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap(); + r["selected"].as_bool() + }; + assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it +/// (the pick's aux pass runs the session's expressions). +#[test] +fn a_pick_on_a_precomp_follows_expressions() { + let mut s = effectcraft_host::session(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone(); + let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap(); + assert_eq!(r["selection"], "Ball", "{r}"); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0)); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null); +} diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index b84b64b79..034fb50fd 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -148,6 +148,31 @@ impl AuxChannels { self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect() } + /// The Cryptomatte layer an effect asking for `want` (`CryptoObject`, …) reads: an exact + /// match, else one ending in it (`ViewLayer.CryptoObject`), else the first layer. + pub fn resolve_crypto_layer(&self, want: &str) -> Option { + let layers = self.crypto_layers(); + layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned() + } + + /// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte + /// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with + /// no positive coverage don't count. `None` when nothing covers the pixel. + pub fn crypto_id_at(&self, layer: &str, i: usize) -> Option { + let mut ids: Vec<(u32, f32)> = vec![]; + for (id, cov) in self.crypto_rank_planes(layer) { + let (Some(id), Some(&c)) = (id.get(i), cov.get(i)) else { continue }; + if c > 0.0 { + match ids.iter_mut().find(|(h, _)| *h == id.to_bits()) { + Some((_, sum)) => *sum += c, + None => ids.push((id.to_bits(), c)), + } + } + } + // `min_by` keeps the first of equal elements: a tie goes to the lower rank. + ids.into_iter().min_by(|a, b| b.1.total_cmp(&a.1)).map(|(h, _)| h) + } + /// Aux pixel index of a layer-space point (nearest; `None` outside). #[inline] pub fn index_at(&self, lx: f64, ly: f64) -> Option { @@ -211,6 +236,28 @@ pub fn crypto_float(hash: u32) -> f32 { mod tests { use super::*; + /// The picked ID: most coverage (summed over ranks), ties to the lower rank, nothing when + /// no rank covers the pixel; layers resolve as the Cryptomatte effect does. + #[test] + fn crypto_id_at_takes_the_top_coverage_and_layers_resolve_by_suffix() { + let mut a = AuxChannels::new(4, 1, 1.0); + let f = |n: &str| crypto_float(crypto_hash(n)); + let (x, y, z) = (f("x"), f("y"), f("z")); + for (c, v) in [("R", [x, x, x, x]), ("G", [0.3, 0.5, 0.0, 0.2]), ("B", [y, y, y, y]), ("A", [0.6, 0.5, 0.0, 0.3])] { + a.channels.push((format!("ViewLayer.CryptoObject00.{c}"), v.to_vec())); + } + for (c, v) in [("R", [z, z, z, x]), ("G", [0.1, 0.0, -1.0, 0.2])] { + a.channels.push((format!("ViewLayer.CryptoObject01.{c}"), v.to_vec())); + } + a.channels.push(("Other00.R".into(), vec![0.0; 4])); + let l = a.resolve_crypto_layer("CryptoObject").unwrap(); + assert_eq!(l, "ViewLayer.CryptoObject"); + assert_eq!(a.resolve_crypto_layer("CryptoMaterial").as_deref(), Some("Other"), "falls back to the first"); + let at = |i| a.crypto_id_at(&l, i); + assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]); + assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None); + } + #[test] fn murmur3_reference_values() { // Published MurmurHash3_x86_32 test vectors. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..d1e160d7a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -314,26 +314,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn aux(&self) -> Option> { - match &self.layer.source { - LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; - let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?; - // Aux pixels per layer pixel (the layer is the footage at its nominal size). - if f.width > 0 && a.width > 0 { - let mut a = (*a).clone(); - a.scale = a.width as f64 / f.width as f64; - return Some(Arc::new(a)); - } - Some(a) - } - LayerSource::Comp { item } => { - if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { - return None; - } - self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new) - } - _ => None, - } + self.r.layer_aux(self.ctx, self.layer) } fn params_at(&self, layer_time: f64) -> Option { @@ -2103,6 +2084,34 @@ impl<'a> Renderer<'a> { Some(three_d::compose::aux_pass(self, &ctx)) } + /// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer + /// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's + /// generated 3D channels at its nested time. `None` for other layers. + pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + match &layer.source { + LayerSource::Footage { item } => { + let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; + let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + // Aux pixels per layer pixel (the layer is the footage at its nominal size). The + // planes are only copied when that changes the scale. + let scale = a.width as f64 / f.width as f64; + if f.width > 0 && a.width > 0 && a.scale != scale { + let mut a = (*a).clone(); + a.scale = scale; + return Some(Arc::new(a)); + } + Some(a) + } + LayerSource::Comp { item } => { + if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) { + return None; + } + self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new) + } + _ => None, + } + } + /// Evaluation context of comp `comp_id` at comp time `t`. pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option> { Some(self.ctx(comp_id, self.project.comp(comp_id)?, t)) diff --git a/docs/agents.md b/docs/agents.md index c47bfb603..01708926b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -180,6 +180,7 @@ Short recipes; every step is one tool call. | Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` | | Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` | | Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` | +| Key objects by Cryptomatte | `add_effect {"layer":2,"effect":"Cryptomatte"}` → `execute_command effect.pickCryptomatte {"layer":2,"effect":1,"x":120,"y":80}` (the ID under that layer pixel replaces the Selection; `"mode":"add"` / `"remove"` edit it; `effect.cryptomatteAt` with the same point only reports the name) → `render_frame` | | Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` | | Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) | | Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` | @@ -323,8 +324,8 @@ build real projects through these interfaces; they are worked examples of everyt ### Effect positions Effect points, Puppet pins (`puppet.addPin`, `puppet.recordPin`), Paint, Liquify and Roto Brush -strokes and `effect.pickColor` are in **effect space**: layer pixels from the top-left of the -layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape +strokes, `effect.pickColor` and `effect.pickCryptomatte` are in **effect space**: layer pixels +from the top-left of the layer's effect bounds. For footage, solids and precomps that is the layer's own pixel grid. Shape and text layers have comp-sized bounds centred on their content's origin, so in a `w` × `h` comp the point `[w / 2, h / 2]` is that origin (a new effect's default point). `prop.get` / `prop.set` read and write these values as they are. From d74cb70938a2096bd200bc00b186db484aadc909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= Date: Mon, 19 Oct 2026 08:40:16 +0200 Subject: [PATCH 0146/1386] Cryptomatte pick: snap to the effect's input buffer grid, not the preview scale At preview scales from 0.75 to 1 footage keeps native pixels, a proxy sets its own buffer scale at any render scale, a Preserve Resolution precomp is drawn at full size, and earlier effects can pad or offset the buffer, so snapping to the preview grid could pick a different ID than the rendered matte. Use the scale and offset of the buffer the Cryptomatte effect receives (layer_input, rendered once and then cached). The pixel centre goes to an aux point through effectcraft_effects::aux_point, which index_map_at now calls too, so the two round identically (a division vs the reciprocal picked the other ID on a 104 px proxy of 1920 px footage). An unknown effect uid errors "no such effect" and a layer without pixels "this layer has no pixels", as effect.pickColor does. --- crates/effects/src/channel3d.rs | 14 ++- crates/effects/src/lib.rs | 2 +- crates/engine/src/commands/effect.rs | 22 +++- crates/engine/src/tests_crypto_pick.rs | 163 ++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7c4f0e1df..9eb8f4dd9 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> { /// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); - let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); - a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) + let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]); + a.index_at(x, y) }) .collect() } +/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel +/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls +/// it too, so the two can't pick different aux pixels. +#[inline] +pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] { + let inv = 1.0 / scale.max(1e-9); + [(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv] +} + /// A channel sampled into the buffer grid (`fill` outside). fn plane_of(b: &Buf, idx: &[Option], ch: &[f32], fill: f32) -> Plane { Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 773bef07c..3295cdc99 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -111,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, aux_point, crypto_aux_image, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index fca4baa5b..4f263b6fb 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -212,7 +212,8 @@ impl CryptoHit { /// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for /// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point -/// snaps to the centre of the pixel a render at `scale` (the viewer's resolution) samples. +/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` +/// (the viewer's resolution). fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; @@ -222,23 +223,32 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; - let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == fx_uid)).ok_or_else(|| bad(cmd, "no such effect"))?; + let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?; + let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; + let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let t = super::time_p(s, p, Some(comp)); let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); - let aux = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).layer_aux(&ctx, layer); + let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }); + let aux = r.layer_aux(&ctx, layer); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); }; - let snap = |v: f64| ((v * scale).floor() + 0.5) / scale; - let Some(hash) = aux.index_at(snap(x), snap(y)).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; + // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, + // proxies have their own size, earlier effects can pad it), rendered and cached. + let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (bs, o) = (b.scale, b.offset); + // The centre of its pixel under the point, at the aux point the effect reads there. + let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]); + let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) } -/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. +/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per +/// frame renders the effect's input to learn its pixel grid (cached afterwards). fn crypto_at(s: &mut Session, p: &Value) -> Result { Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) } diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs index 4fdbcbf41..799027c7e 100644 --- a/crates/engine/src/tests_crypto_pick.rs +++ b/crates/engine/src/tests_crypto_pick.rs @@ -46,8 +46,8 @@ fn aux_at(t: Tick) -> AuxChannels { } impl FootageSource for Crypto { - fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { - Some(Arc::new(Image::new(8, 4))) + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) } fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { Some(Arc::new(aux_at(t))) @@ -58,11 +58,13 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let duration = Tick::from_seconds_f64(2.0); + // A half-size proxy of the EXR. + let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) }; Ok(Footage { path: path.into(), kind: FootageKind::Video, - width: 8, - height: 4, + width, + height, has_video: true, frame_rate: FrameRate::from_f64(24.0), duration, @@ -179,3 +181,156 @@ fn picks_on_a_precomp_of_a_3d_comp() { let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); } + +/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under +/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there. +fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) { + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1; + s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); + let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); + let (px, py) = buf.to_px([x, y]); + let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; + (r["name"].as_str().unwrap_or("?").to_string(), m) +} + +/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples +/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1. +#[test] +fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() { + let mut s = session(); + let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, COMMA); +} + +/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution: +/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie). +#[test] +fn pick_on_a_proxy_matches_the_proxy_sized_buffer() { + let mut s = session(); + let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0; + s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, "left"); +} + +/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy. +struct Edge; +impl FootageSource for Edge { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let (w, h) = (1920usize, 40usize); + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect(); + aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])]; + aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + Some(Arc::new(aux)) + } +} + +/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but +/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree. +#[test] +fn pick_maps_pixel_centres_bit_exactly_as_the_effect() { + let mut s = Session { footage: Arc::new(Edge), ..Default::default() }; + let at = |path: &str, width| Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height: 40, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104)))); + let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0); + assert_eq!((name.as_str(), m), ("left", 1.0)); +} + +struct EdgeProbe(Footage, Footage); +impl crate::Importer for EdgeProbe { + fn probe(&self, path: &str) -> Result { + Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() }) + } +} + +/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another +/// resolution, a relinked file): the render stretches them over the layer. +struct OffNominal(u32, u32); +impl FootageSource for OffNominal { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point. +fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator + Clone, ys: &[f64]) -> Vec { + let mut bad = vec![]; + for &scale in scales { + for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) { + if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() { + continue; + } + let (name, m) = rendered_matte_of_pick(s, x, y, scale); + if m < 0.5 { + bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}")); + } + } + } + bad +} + +const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25]; + +/// The pick follows the decoded frame's real size, not the footage's nominal one. +#[test] +fn pick_matches_the_render_when_frames_decode_off_nominal_size() { + for (num, den) in [(2, 1), (1, 2), (3, 4)] { + let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]); + assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} + +/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full +/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows. +#[test] +fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() { + for preserve in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0; + s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]); + assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} From 26636bfb18418ed31daea8b1cb61f36d2d50f19d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:00:02 +0000 Subject: [PATCH 0147/1386] Make Fractal Noise evolution progress linearly Fixes #1816 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/effects/src/noise3.rs | 20 +++++++++++++++++--- crates/gpu/src/shaders/fx_noise.wgsl | 2 +- crates/gpu/src/shaders/kernels.wgsl | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..39980dd80 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -17,7 +17,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use rayon::prelude::*; -use crate::generate::{fade, lattice}; +use crate::generate::lattice; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider}; /// Fractal Type options (shared by both effects). @@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] { } /// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the -/// evolution axis z always fades smoothly). +/// evolution axis z always interpolates linearly, so evolution moves at a constant rate). /// /// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic /// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values @@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 { // Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's. let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 }; let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor()); - let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0)); + let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let slice = |dz: i32| -> f32 { @@ -476,6 +476,20 @@ mod tests { run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img } + /// Evolution moves the noise at a constant rate: no ease in or out within a revolution. + #[test] + fn evolution_is_linear() { + for noise_type in [0, 1, 2, 3] { + let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type); + let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type); + for z in [0.25, 0.5, 0.75] { + let want = a + (b - a) * z; + let got = typed_noise(0.3, 0.7, z, 5, noise_type); + assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}"); + } + } + } + /// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and /// the new controls take values that keep the old look. #[test] diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 1f62d4429..5e68e10c1 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -538,7 +538,7 @@ fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { let z0 = floor(z); let tx = x - x0; let ty = y - y0; - let fz = fade(z - z0); + let fz = z - z0; let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); return a + (b - a) * fz; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..85ae25476 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -561,7 +561,7 @@ fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { let wy = bspline(y + 0.5 - y0); let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed); let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed); - let n = a + (b - a) * fade(z - z0); + let n = a + (b - a) * (z - z0); return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN; } From ae5d090aed089d007b2b8537ef80246e595da661 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:16:41 +0200 Subject: [PATCH 0148/1386] =?UTF-8?q?Hold=20position=20between=20identical?= =?UTF-8?q?=20auto-B=C3=A9zier=20spatial=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1675 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/keyframe/src/lib.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index e0ee137ba..83774764d 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -327,7 +327,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { let a = v3(&keys[i.saturating_sub(1)].value); let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; - ([-tout[0], -tout[1], -tout[2]], tout) + // A side facing a key at the same position gets no tangent, so the segment between two + // identical keys has zero length and the layer holds still (#1675). + let p = v3(&keys[i].value); + let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; + let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; + (tin, tout) } /// Effective spatial tangents of key `i` (auto or stored). @@ -928,6 +933,26 @@ mod tests { assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); } + #[test] + fn auto_spatial_holds_between_identical_keys() { + let path = [(0.0, [0.0, 540.0]), (0.6, [400.0, 540.0]), (4.4, [400.0, 540.0]), (5.0, [0.0, 540.0])]; + for eased in [false, true] { + let keys: Vec = path + .iter() + .map(|&(t, v)| { + let k = Keyframe::new(s(t), Value::Vec2(v)); + if eased { k.eased() } else { k } + }) + .collect(); + assert_eq!(spatial_tangents(&keys, 1).1, [0.0; 3]); + assert_eq!(spatial_tangents(&keys, 2).0, [0.0; 3]); + for t in [0.7, 1.0, 2.5, 4.0, 4.3] { + let p = evaluate(&keys, s(t), true).unwrap().as_vec3(); + assert!((p[0] - 400.0).abs() < 1e-6 && (p[1] - 540.0).abs() < 1e-6, "eased={eased} t={t} p={p:?}"); + } + } + } + #[test] fn set_key_replaces_same_time() { let mut keys = vec![]; From bf7aff11f89f1461515bdbc7ab37fd5d56169ca9 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:54:16 +0200 Subject: [PATCH 0149/1386] Timeline: layer switches act on every selected layer (#1849) A switch click on one of several selected layers passed that layer alone to `layer.setSwitch`, so Lock, 3D, Video (and the rest of the A/V Features and Switches columns) had to be set one layer at a time. The click now gives the switch to the whole selection, with the layers it doesn't apply to left out (3D on a camera, Effect on a layer without effects, Audio on a layer without audio, ...). The targets are picked when the press happens, so drawing a row costs nothing more than before. A row that isn't selected still changes that layer alone, pressing and dragging over rows is unchanged, and Alt-clicking an A/V feature sets the selected layers apart. --- crates/ui-egui/src/panels/timeline.rs | 109 ++++++++++--- crates/ui-egui/tests/ui_batch_switches.rs | 179 ++++++++++++++++++++++ 2 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/ui_batch_switches.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..d6b79744f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1314,6 +1314,33 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): +/// the selection when the row is selected, else that layer alone, as in After Effects. +fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { + if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } +} + +/// Whether the Timeline shows switch `name`'s cell on `layer`'s row (A/V Features and Switches +/// columns): a layer that has no such cell isn't one a click gives the switch to either. +fn switch_applies(layer: &Layer, name: &str, project: &Project) -> bool { + match name { + "video" | "lock" | "shy" => true, + "audio" => layer.props.sub("audio").is_some(), + "solo" | "quality" | "motionBlur" | "adjustment" => layer.source.is_av(), + "collapse" => collapse_applies(layer, project), + "fx" => layer.effects().is_some_and(|f| !f.children.is_empty()), + "frameBlend" => matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + "threeD" => layer.source.is_av() || matches!(layer.source, LayerSource::Null), + _ => true, + } +} + +/// The layers a click on the `name` switch of `layer`'s row acts on: [`edited_layers`] without +/// the layers the switch doesn't apply to. +fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u64, name: &str) -> Vec { + edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -1328,20 +1355,29 @@ fn layer_switch( layer: u64, name: &'static str, others: bool, + comp: &Comp, + project: &Project, + selected: &[LayerId], actions: &mut Vec<(String, serde_json::Value)>, ) { let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), - // and come back on the next Alt-click. + // and come back on the next Alt-click. With several layers selected, the whole selection is + // set apart. if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true}))); + let layers = edited_layers(LayerId(layer), selected); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "others": true}))); return; } + // A press on a selected layer's switch gives the switch to every selected layer, as in After + // Effects; the targets are picked when the press happens, never while the row is drawn. if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation (a pointer press was handled above). - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": !on}))); } } @@ -2000,10 +2036,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // as in After Effects: it hides a null's outline in the viewer and turns a camera or // light off. let av_items: [(Icon, bool, &str, bool); 4] = [ - (Icon::Eye, sw.video, "video", true), - (Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()), - (Icon::Solo, sw.solo, "solo", layer.source.is_av()), - (Icon::Lock, sw.locked, "lock", true), + (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)), + (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)), + (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)), + (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() { let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0)); @@ -2011,7 +2047,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("av", layer.id.0, i)), + layer.id.0, + name, + true, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } // Label swatch. @@ -2122,19 +2173,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ - (Icon::Shy, sw.shy, "shy", true), - (Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)), - (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()), - (Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())), + (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)), + (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)), + ( + Icon::Quality, + sw.quality == effectcraft_engine::project::Quality::Best, + "quality", + switch_applies(layer, "quality", &app.session.project), + ), + (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)), ( Icon::FrameBlend, sw.frame_blend != effectcraft_engine::project::FrameBlend::Off, "frameBlend", - matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + switch_applies(layer, "frameBlend", &app.session.project), ), - (Icon::MotionBlur, sw.motion_blur, "motionBlur", layer.source.is_av()), - (Icon::Adjustment, sw.adjustment, "adjustment", layer.source.is_av()), - (Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)), + (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)), + (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)), + (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() { if !applicable || !vis.switches { @@ -2142,7 +2198,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("sw", layer.id.0, i)), + layer.id.0, + name, + false, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } // Modes. diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs new file mode 100644 index 000000000..8534c77f4 --- /dev/null +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -0,0 +1,179 @@ +//! A Timeline switch clicked on one of several selected layers applies to the whole selection +//! (#1849), as in After Effects. The engine commands already took several layers; the row +//! controls used to pass the clicked layer alone. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerId, Switches}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids: Vec = ["D", "C", "B", "A"] + .iter() + .map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap()) + .collect(); + ids.reverse(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Press on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A click on the Timeline control `id`. +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + drag(h, p, p, 1); +} + +fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + let modifiers = Modifiers::ALT; + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would. +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +/// The state of switch `f` on each layer of `ids`, top to bottom. +fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec { + let comp = h.state().session.active_comp().unwrap(); + ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect() +} + +fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone() +} + +fn locked(l: &Layer) -> bool { + l.switches.locked +} + +fn video(l: &Layer) -> bool { + l.switches.video +} + +fn three_d(l: &Layer) -> bool { + l.switches.three_d +} + +fn solo(l: &Layer) -> bool { + l.switches.solo +} + +fn shy(l: &Layer) -> bool { + l.switches.shy +} + +fn motion_blur(l: &Layer) -> bool { + l.switches.motion_blur +} + +/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the +/// switch to every selected layer, in one undo step. +#[test] +fn switches_on_a_selected_row_set_the_whole_selection() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + + let steps = h.state().session.history.undo.len(); + click(&mut h, &format!("timeline.layer.{}.lock", ids[0])); + assert_eq!(states(&h, &ids, locked), [true, false, true, false]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, locked), [false; 4]); + + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0])); + assert_eq!(states(&h, &ids, three_d), [true, false, true, false]); + // The switch of the other selected layer acts the same way. + click(&mut h, &format!("timeline.layer.{}.solo", ids[2])); + assert_eq!(states(&h, &ids, solo), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2])); + assert_eq!(states(&h, &ids, shy), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0])); + assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]); + // A second click turns both off again, whatever their own state. + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2])); + assert_eq!(states(&h, &ids, three_d), [false; 4]); +} + +/// A switch on a row that isn't selected still changes that layer alone, and leaves the +/// selection. +#[test] +fn a_switch_on_an_unselected_row_sets_that_layer_only() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.lock", ids[1])); + assert_eq!(states(&h, &ids, locked), [false, true, false, false]); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]); +} + +/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a +/// camera (3D by kind), the Effect switch not a layer without effects. +#[test] +fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + select(&mut h, &[solid, null, cam]); + + click(&mut h, &format!("timeline.layer.{solid}.switch.threeD")); + assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null"); + assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off"); + + click(&mut h, &format!("timeline.layer.{solid}.switch.fx")); + assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect"); + assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch"); +} + +/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next +/// Alt-click brings the others back — with several layers, the whole selection stands apart. +#[test] +fn alt_click_sets_the_selected_layers_apart() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true, false, true, false]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true; 4]); +} From 16d43346c4d5e92720360981bf8886c5d23e92f7 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:55:24 +0200 Subject: [PATCH 0150/1386] Timeline: the label swatch and the Modes column act on the selection (#1849) The label swatch's menu, the Blending Mode menu and Preserve Transparency passed the clicked layer alone, so a colour or a mode had to be set one layer at a time. They now act on the whole selection, with the Modes column edits going only to the selected layers that have video, as its cells do. The preserve-transparency checkbox also gets an automation id, like the cells around it. --- crates/ui-egui/src/panels/timeline.rs | 13 ++++-- crates/ui-egui/tests/ui_batch_switches.rs | 52 ++++++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d6b79744f..ebe076dc8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1341,6 +1341,12 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() } +/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) +/// acts on: [`edited_layers`] that have video. +fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { + edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -2077,7 +2083,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, pop); } if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) { - actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); + actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()}))); } } if vis.num { @@ -2230,11 +2236,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }) .collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); + app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); + actions.push(("layer.setSwitch".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "switch": "preserveTransparency"}))); } // Track matte: layer picker + kind. let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs index 8534c77f4..acbc91f87 100644 --- a/crates/ui-egui/tests/ui_batch_switches.rs +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -1,12 +1,15 @@ -//! A Timeline switch clicked on one of several selected layers applies to the whole selection -//! (#1849), as in After Effects. The engine commands already took several layers; the row +//! A Timeline row control clicked on one of several selected layers applies to the whole +//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label +//! swatch and the Modes column. The engine commands already took several layers; the row //! controls used to pass the clicked layer alone. use effectcraft_engine::Session; +use effectcraft_engine::color::{BlendMode, Label}; use effectcraft_engine::project::{Layer, LayerId, Switches}; use effectcraft_ui_egui::EffectcraftApp; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; /// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. @@ -49,6 +52,11 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { drag(h, p, p, 1); } +/// A click at `p` (a menu entry, found by its label). +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + drag(h, p, p, 1); +} + fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { let p = rect(h, id).center(); let modifiers = Modifiers::ALT; @@ -102,6 +110,10 @@ fn motion_blur(l: &Layer) -> bool { l.switches.motion_blur } +fn preserved(l: &Layer) -> bool { + l.preserve_transparency +} + /// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the /// switch to every selected layer, in one undo step. #[test] @@ -177,3 +189,39 @@ fn alt_click_sets_the_selected_layers_apart() { alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); assert_eq!(states(&h, &ids, video), [true; 4]); } + +/// The label swatch menu of a selected layer sets the label of every selected layer. +#[test] +fn the_label_menu_sets_the_selections_label() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + let green = h.state().session.prefs.label_name(Label::Green); + click(&mut h, &format!("timeline.layer.{}.label", ids[0])); + let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + click_at(&mut h, entry.center()); + let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label; + assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]); +} + +/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected +/// layer set both selected layers, one undo step each. +#[test] +fn the_modes_column_sets_the_whole_selection() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + select(&mut h, &[ids[0], ids[2]]); + + click(&mut h, &format!("timeline.layer.{}.mode", ids[0])); + let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect(); + click_at(&mut h, entry.center()); + let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode; + assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]); + + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back"); +} From 4f3fa4d0fb914b89a3d1fa4a3375b5cdf8d6466e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:55:40 +0200 Subject: [PATCH 0151/1386] docs: the Timeline row controls act on the selection (#1849) One bullet in the Timeline section of docs/agents.md and a note on the ui-parity Timeline row, next to the other multi-selection fixes. --- docs/agents.md | 6 ++++++ docs/ui-parity.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/agents.md b/docs/agents.md index c47bfb603..f4fcc96f3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -595,6 +595,12 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the selected layers open or closed. With no layer selected they apply to every layer. +* A row control clicked on one of several selected layers applies to every selected layer, as in + After Effects: the A/V switches (video, audio, solo, lock), the Switches column (shy, collapse, + quality, fx, frame blending, motion blur, adjustment, 3D), the label swatch and the Modes column + (blending mode, Preserve Transparency), one undo step, with the layers the switch doesn't apply + to left out. A control on an unselected row changes that layer alone; Alt-clicking an A/V feature + still sets the selected layers apart from the rest. * `keys.toggleTransform {"layers"?: [...], "prop": "position"}` (Alt+Shift+A/P/S/R/T) adds or removes a keyframe at the current time on that Transform property of each layer, in one undo step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ccce70e0f..f2956918e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | From 7930fb201994ad38d642136fb22fc03099d7173f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:09:07 +0200 Subject: [PATCH 0152/1386] Use frozen Object Matte in sessions without the freezing model Fixes #1457 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/object_matte.rs | 49 +++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index 32a196af2..27d29ccbd 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -191,6 +191,11 @@ pub fn roto_data(params: &Params, layer_size: [f64; 2], f: i64, with_model: bool /// Seed of the chain keys: what the frames are (the Input Key), the layer size, the frame rate, /// the mode and the model. The prompt is in each frame's key. pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> u64 { + seed_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`seed`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn seed_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325; fnv(&mut h, ID.as_bytes()); fnv(&mut h, s(params, INPUT_KEY).as_bytes()); @@ -198,7 +203,7 @@ pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn fnv(&mut h, &v.to_bits().to_le_bytes()); } fnv(&mut h, &[tracks(params) as u8]); - fnv(&mut h, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id).as_bytes()); + fnv(&mut h, model.as_bytes()); h } @@ -255,9 +260,14 @@ pub fn frame_keys(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64 /// What frozen mattes depend on: what the frames are (the seed: Input Key, size, rate, mode, /// model), the prompt, the tracked span and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64, model: Option<&dyn MaskModel>) -> String { - let mut h = seed(params, layer_size, fps, model); + frozen_key_of(params, layer_size, fps, model.map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: no model). +fn frozen_key_of(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { + let mut h = seed_of(params, layer_size, fps, model); let base = reference_frame(params); - if let Some(st) = prompt_strokes(params, layer_size, base, model.is_some()) { + if let Some(st) = prompt_strokes(params, layer_size, base, model != effectcraft_segment::CLASSICAL) { fnv(&mut h, serde_json::to_string(&st).unwrap_or_default().as_bytes()); } for v in [base, reach(params)[0], reach(params)[1]] { @@ -273,7 +283,9 @@ pub fn is_frozen(params: &Params) -> bool { } /// Frame `f`'s frozen matte on a `w`×`h` grid, when the instance is frozen, the mattes still -/// match ([`frozen_key`]) and `f` is one of them. +/// match ([`frozen_key`]) and `f` is one of them. The mattes match under the session's `model` +/// or under the model that froze them (which this session may not have loaded, e.g. a fresh +/// headless render): frozen mattes need no segmentation. pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: usize, h: usize, model: Option<&dyn MaskModel>) -> Option> { let j = s(params, FROZEN); if j.is_empty() { @@ -281,7 +293,10 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w: } let fc = frozen_cache(j)?; if fc.key != frozen_key(params, layer_size, fps, model) { - return None; + let ids = std::iter::once(effectcraft_segment::CLASSICAL).chain(effectcraft_segment::MODELS.iter().map(|m| m.id)); + if !ids.into_iter().any(|id| fc.key == frozen_key_of(params, layer_size, fps, id)) { + return None; + } } let fr = fc.frames.get(&f)?; let a = rle::decode(&fr.a)?; @@ -688,4 +703,28 @@ mod tests { p.values.insert(FROZEN.into(), Value::Str(fc2.to_json())); assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, None), None); } + + /// Mattes frozen with a model stay usable in a session where that model is not loaded (a + /// fresh headless render): they are used as stored, not re-segmented with another model. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut p = params(&[ + ("objectPoint", Value::Vec2([40.0, 40.0])), + ("useBox", Value::Bool(true)), + ("boxTopLeft", Value::Vec2([10.0, 10.0])), + ("boxBottomRight", Value::Vec2([90.0, 70.0])), + ("objectTracking/framesAfter", Value::Scalar(3.0)), + ]); + p.values.insert(INPUT_KEY.into(), Value::Str("frozen-model-test".into())); + let stub = Stub::default(); + let alpha: Vec = (0..100 * 80).map(|i| ((i % 100) < 50) as u8 as f32).collect(); + let mut fc = FrozenCache { key: frozen_key(&p, [100.0, 80.0], 25.0, Some(&stub)), size: [100, 80], ..Default::default() }; + fc.frames.insert(2, roto::freeze_frame(&alpha, &[])); + p.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + assert!(is_frozen(&p)); + assert!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 100, 80, Some(&stub)).is_some()); + assert_eq!(frozen_alpha(&p, [100.0, 80.0], 25.0, 2, 50, 40, None).map(|a| (a[20 * 50 + 5], a[20 * 50 + 45])), Some((1.0, 0.0))); + let out = run(&p, 2); + assert!(out.data[40 * 100 + 10][3] > 0.99 && out.data[40 * 100 + 90][3] < 0.01, "the frozen matte is used"); + } } From f72e17a2bb2a594375e813a9cae4f9459e7906b4 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 10:25:17 +0200 Subject: [PATCH 0153/1386] Engine: Video's Alt-click and Hide Other Video take cameras and lights along The Timeline draws the Video switch (the eye) on every layer's row, nulls, cameras and lights included, but set_switch_others and hide_other_video skipped cameras and lights: Alt-clicking the eye of a selected layer neither set them apart with the selection nor hid them with the rest. Both now use the rule the eye cell shows: every layer but track mattes. --- crates/engine/src/commands/layer.rs | 6 ++++-- crates/engine/src/commands/layer_menu.rs | 8 ++++---- crates/engine/src/tests_menu_cmds.rs | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..0832163d1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -417,8 +417,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, let applies = |l: &Layer| match name { "audio" => l.props.sub("audio").is_some(), "lock" | "locked" => true, - // Nulls have a Video switch too (it hides their outline in the viewer). - "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), + // Every layer has a Video switch (it hides a null's outline in the viewer and turns a + // camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off + // by design. + "video" | "eye" => !mattes.contains(&l.id), _ => l.source.is_av() && !mattes.contains(&l.id), }; let mut alone = true; diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 9cac80412..064b2fb33 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { fn hide_other_video(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Hide Other Video", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() { - if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) { - l.switches.video = false; - } + // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's + // eye cell shows): the selection stays shown. + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + l.switches.video = false; } Ok(()) })?; diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd40..d50257aa6 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,29 @@ fn set_switch_others() { assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); } +/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking +/// it (and Hide Other Video) takes them along with the rest. +#[test] +fn video_others_takes_cameras_and_lights() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l))); + // The camera and the light are hidden: Alt-click on the solid's eye brings them back, since + // they stand apart with it, and the next Alt-click hides them with every other layer. + s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); + // Hide Other Video takes them too. + s.execute("layer.showAllVideo", json!({})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); +} + /// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles, /// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is /// hidden (parenting ignores the eye). From 8756ec1846f26537e80d44383b0b6a15d74fca5b Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 10:25:17 +0200 Subject: [PATCH 0154/1386] Timeline: Preserve Transparency toggles from the clicked row (#1849) layer.setSwitch with no value toggles relative to the first selected layer in composition order, so with a mixed selection (A checked, C unchecked) clicking C's box could leave C unchanged. The checkbox now sends the value it toggles to, as the switches do. --- crates/ui-egui/src/panels/timeline.rs | 11 ++++++++++- crates/ui-egui/tests/ui_batch_switches.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ebe076dc8..1366b7504 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2241,7 +2241,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "switch": "preserveTransparency"}))); + // The click gives the selection the state the clicked row toggles to, + // whatever the first selected layer's value (as the switches do). + actions.push(( + "layer.setSwitch".into(), + json!({ + "layers": mode_targets(&comp, layer.id, &selected), + "switch": "preserveTransparency", + "value": !layer.preserve_transparency + }), + )); } // Track matte: layer picker + kind. let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs index acbc91f87..407ac194e 100644 --- a/crates/ui-egui/tests/ui_batch_switches.rs +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -225,3 +225,20 @@ fn the_modes_column_sets_the_whole_selection() { h.run_steps(2); assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back"); } + +/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer: +/// A on and C off, clicking C's box turns both on. +#[test] +fn preserve_transparency_toggles_from_the_clicked_row() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap(); + h.run_steps(2); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + // The next click turns both off again. + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [false; 4]); +} From c17f9eb959a4d27843dbabb905a0bea2feae3d0c Mon Sep 17 00:00:00 2001 From: SBaksa Date: Mon, 19 Oct 2026 21:08:16 +1100 Subject: [PATCH 0155/1386] Text Range Selector: Start, End and Offset show as counts with Units = Index (#1814) With Units set to Index the selector already counts characters (words, lines), but the timeline kept showing Start, End and Offset as percentages ("3.0%"), so switching looked like it did nothing. They are now shown without the % sign while Units is Index (render::text::in_index_units). --- crates/render/src/text.rs | 8 ++++++++ crates/render/src/text/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 4 +++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..6abc2ae96 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 { if units_index { (v * n as f64).round() } else { v * 100.0 } } +/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units +/// are Index: the value is then a count of characters (words, lines), not a percentage. +pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool { + let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false }; + let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset")); + is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1)) +} + /// Selection of every glyph by an animator's selectors, per dimension. pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> { let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..7721d1fb2 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() { assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); } +/// With Units = Index a Range Selector's Start, End and Offset are character counts, not +/// percentages (#1814); the selector's other values, and other selectors, are unaffected. +#[test] +fn index_units_mark_start_end_and_offset() { + let (mut p, _cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0)); + animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]); + let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let base = "text/animators/#1/selectors/#1"; + let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}"))); + assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default"); + set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1)); + assert!(range_values.iter().all(|u| in_index_units(&l, *u))); + assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage"); + assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity"))); + assert!(!in_index_units(&l, uid(&l, "transform/opacity"))); +} + #[test] fn range_start_end_and_offset_are_capped() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..493fd63b4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4117,7 +4117,9 @@ fn value_editor( set(actions, json!(nv)); } } else { - let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" }; + // A Range Selector's Start, End and Offset count characters with Units = Index (#1814). + let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid); + let suffix = if pct { "%" } else { "" }; let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { From 80158bb69bafa94f7b63c27cb79d4b9cbe8c0a9e Mon Sep 17 00:00:00 2001 From: dorfee Date: Mon, 19 Oct 2026 16:45:39 +0300 Subject: [PATCH 0156/1386] ScriptUI: install scheduled task runner in host sessions --- crates/host/src/lib.rs | 22 ++++++++++++++++++---- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/tests/ui_scriptui.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index bdb4e2bc3..577492a53 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -82,7 +82,7 @@ impl Exporter for FileExporter { /// A new session with media, import, expressions, scripting and export enabled. pub fn session() -> Session { - Session { + let mut s = Session { // The desktop app, the CLI and the MCP server share installed Roto Brush models. models_dir: config_dir().map(|d| d.join("models")), exporter: Some(Arc::new(FileExporter::default())), @@ -90,11 +90,11 @@ pub fn session() -> Session { importer: Some(Arc::new(MediaImporter)), expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), - script: Some(effectcraft_script::runner), - script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() }, plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader), ..Default::default() - } + }; + effectcraft_script::install(&mut s); + s } /// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides): @@ -109,6 +109,20 @@ pub fn config_dir() -> Option { mod tests { use serde_json::json; + #[test] + fn host_runs_tasks_scheduled_by_panel_handlers() { + let mut s = super::session(); + let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Timer regression'); var b=w.add('button',undefined,'Import'); b.onClick=function(){app.scheduleTask(\"app.project.items.addComp('Scheduled',64,64,1,1,25)\",0,false);};w.show();"})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + s.execute("scriptui.click", json!({"window":"Timer regression","widget":"Import"})).unwrap(); + assert!(s.project.items.is_empty()); + let tick = s.execute("script.runTasks", json!({})).unwrap(); + assert_eq!(tick["ran"], 1, "{tick}"); + assert_eq!(tick["errors"], json!([]), "{tick}"); + assert!(s.project.items.values().any(|item| item.name == "Scheduled")); + s.execute("scriptui.close", json!({"window":"Timer regression"})).unwrap(); + } + /// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions /// evaluate to the camera's focus on the target. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..c330bcc3f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1775,7 +1775,11 @@ impl EffectcraftApp { if self.session.script_ui.task_hosts.is_empty() { return; } + let previous_comp = self.session.active_comp_id(); let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return }; + if self.session.active_comp_id().is_some() && self.session.active_comp_id() != previous_comp { + self.ui.start_screen = false; + } if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) { self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time))); } else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) { diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index 6cfc2db71..31fb555d3 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -271,3 +271,18 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { } h.run_steps(3); } +#[test] +fn scheduled_import_reveals_its_composition_without_manual_clicks() { + let mut s = effectcraft_host::session(); + let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Automatic import');var b=w.add('button',undefined,'Open');b.onClick=function(){app.scheduleTask(\"var c=app.project.items.addComp('Imported',64,64,1,1,25);c.openInViewer();\",0,false);};w.show();"})).unwrap(); + assert_eq!(result["ok"], true, "{result}"); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.start_screen = true; + h.run_steps(2); + h.state_mut().session.execute("scriptui.click", json!({"window":"Automatic import","widget":"Open"})).unwrap(); + h.run_steps(4); + let active = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.project.items.get(&active).unwrap().name, "Imported"); + assert!(!h.state().ui.start_screen, "Home must not hide a composition opened by the scheduled import"); + h.state_mut().session.execute("scriptui.close", json!({"window":"Automatic import"})).unwrap(); +} From 71aafd6307cf7c6812fba0f2fcfe6229debabb7d Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 01:05:15 +1100 Subject: [PATCH 0157/1386] Text Range Selector: test the Timeline shows Index-unit values without % (#1814) Start, End and Offset are read as painted in the Timeline: with % under Units = Percentage, without it under Units = Index. Fails on the old suffix rule ("0.0%"). --- crates/ui-egui/tests/ui_text_range_handles.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/ui_text_range_handles.rs index 1d048be52..aab80100d 100644 --- a/crates/ui-egui/tests/ui_text_range_handles.rs +++ b/crates/ui-egui/tests/ui_text_range_handles.rs @@ -194,3 +194,46 @@ fn a_bar_on_a_box_handle_leaves_it_the_press() { assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays"); assert_ne!(scale(&h), before, "the handle scaled the layer"); } + +/// The text the Timeline paints in property `uid`'s value field. +fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String { + let e = h.state().auto.find(&format!("timeline.prop.{uid}.value")).unwrap_or_else(|| panic!("timeline.prop.{uid}.value isn't shown")); + let field = egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])); + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if field.contains(t.visual_bounding_rect().center()) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() +} + +/// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as +/// counts, without the % sign they carry with Units = Percentage. +#[test] +fn index_units_show_start_end_and_offset_without_a_percent_sign() { + let (s, ids) = setup(); + let l = LayerId(ids.layer); + let layer = s.active_comp().unwrap().layer(l).unwrap().clone(); + let text = layer.props.sub("text").unwrap(); + let anim = animator(&s, ids.layer); + let selectors = anim.sub("selectors").unwrap(); + let sel = selectors.groups().next().unwrap(); + let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid); + let mut h = harness(s); + let tl = &mut h.state_mut().ui.timeline; + tl.open_layers.insert(ids.layer); + let animators = text.sub("animators").unwrap().uid; + tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid]); + h.run_steps(3); + for uid in [ids.start, ids.end, offset] { + assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid)); + } + h.state_mut().session.execute("prop.set", json!({"layer": ids.layer, "prop": units, "value": 1})).unwrap(); + h.run_steps(3); + for uid in [ids.start, ids.end, offset] { + let v = timeline_value(&h, uid); + assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}"); + } +} From fb16d5573e87b150730d94f8794c5fd5e37a4bd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 04:59:38 +0200 Subject: [PATCH 0158/1386] Tint: Rec. 601 luminance weights, as After Effects After Effects 2026 (sRGB working space, not linearised) maps red, green, blue and 50% grey solids at Tint's defaults to 76, 150, 29 and 128/255: Rec. 601 weights on the working-space values, not Rec. 709. CPU and GPU kernel changed; tests pin the four greys within 1/255. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 20 +++++++++++++++++++- crates/gpu/src/shaders/kernels.wgsl | 3 ++- crates/gpu/src/tests.rs | 19 +++++++++++++++++++ crates/render/src/tests.rs | 16 ++++++++++++++++ docs/effects-parity.md | 5 +++-- 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index cf6698fee..b9ec5f5f3 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,12 +14,19 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } +/// Tint's luminance: Rec. 601 weights on the working-space values, as After Effects 2026 +/// renders it (sRGB working space, not linearised: red 76/255, green 150/255, blue 29/255). +/// The GPU kernel (`pointwise` op 1) mirrors it. +fn tint_luma(c: [f32; 3]) -> f32 { + 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] +} + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = tint_luma(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -1173,6 +1180,17 @@ mod tests { } } + /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working + /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. + #[test] + fn tint_defaults_match_after_effects_greys() { + for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); + let want = want / 255.0; + assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); + } + } + #[test] fn colorama_reads_get_phase_from() { // Same intensity, different red: Intensity gives one colour, Red gives two. diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..ce23927af 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as After Effects (effects crate tint_luma). + let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; let t = blk + (wht - blk) * l; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..a0abcd22c 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -851,6 +851,25 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::tint_luma`). +#[test] +fn gpu_tint_defaults_match_after_effects_greys() { + let Some(g) = gpu() else { return }; + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let mut s = Scene::new(BitDepth::Bpc32); + s.p.settings.working_space = Some(ColorSpace::Srgb); + let mut l = s.solid(col, 97, 61); + s.effect(&mut l, "ec.color.tint", &[]); + s.push(l); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..14c110bf8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -254,6 +254,22 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame +/// solids: the effect sees the encoded solid colour and weights it Rec. 601. +#[test] +fn tint_in_an_srgb_working_space_matches_after_effects_greys() { + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let (mut p, cid, comp) = setup(); + p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb); + let mut l = solid(&mut p, &comp, col, 200, 100); + add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]); + p.comp_mut(cid).unwrap().layers.push(l); + let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. #[test] fn card_dance_comp_camera_matches_the_default_view() { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c5fbc5ba8..cf2c6bb7b 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint has Swap Colors (#963) and matches After Effects 2026 renders of primary and grey solids (Rec. 601 luminance); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 960f41d50b30447ee786df2e0b4b09fe811ca0b7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:33:09 +0200 Subject: [PATCH 0159/1386] Tritone: Rec. 601 luminance, as After Effects After Effects' Tritone weighs the working-space values with Rec. 601 (0.299, 0.587, 0.114), as its Tint does; EffectCraft used Rec. 709. Case 718 (red solid, Highlights [1,1,0.8], Midtones [0.6,0.2,0.2], Shadows [0,0,0.3], Blend 25): AE (132, 23, 46), EffectCraft was (113, 16, 49), now (132, 23, 46). On a grey ramp (case 815) the shadows / midtones / highlights mapping already matched and still does within 1/255. CPU and GPU (fx_color op 4). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 38 +++++++++++++++++++++++----- crates/gpu/src/shaders/fx_color.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 2 +- crates/gpu/src/tests.rs | 2 +- docs/effects-parity.md | 3 ++- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index b9ec5f5f3..0040293e0 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,10 +14,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } -/// Tint's luminance: Rec. 601 weights on the working-space values, as After Effects 2026 -/// renders it (sRGB working space, not linearised: red 76/255, green 150/255, blue 29/255). -/// The GPU kernel (`pointwise` op 1) mirrors it. -fn tint_luma(c: [f32; 3]) -> f32 { +/// Rec. 601 luminance on the working-space values, as After Effects 2026 renders Tint, Tritone +/// and the other Rec. 601 effects (sRGB working space, not linearised: red 76/255, green +/// 150/255, blue 29/255). Tint and Tritone use it (Tritone, case 718: a red solid with +/// Blend With Original 25 gives (132, 23, 46), from luminance 0.299; Rec. 709's 0.213 gave +/// (113, 16, 49); on a grey ramp both weightings agree, case 815). The GPU kernels +/// (`pointwise` op 1, `fx_color` op 4) mirror it. +fn luma_601(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } @@ -26,7 +29,7 @@ fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = tint_luma(c).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -39,7 +42,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { let sh = ctx.params.color("shadows"); let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { @@ -1248,6 +1251,29 @@ mod tests { /// #1463: Black & White's sliders set the gray of each pure hue and blend between /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright /// colour stays finite (it used to divide by zero). + /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows + /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); + /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), + /// (153, 51, 51), (204, 153, 128). + #[test] + fn tritone_matches_after_effects() { + let colours = [ + ("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), + ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), + ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])), + ]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0); + let mut red = colours.to_vec(); + red.push(("blend", num(25.0))); + let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0)); + for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] { + let v = level / 255.0; + let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]); + assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0)); + } + } + #[test] fn black_white_sliders_set_the_gray_of_each_hue() { let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 6b8641dd9..38325a944 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -167,7 +167,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as effects::color_fx::tritone. + let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index ce23927af..00d0e8bf2 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,7 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - // Rec. 601 weights, as After Effects (effects crate tint_luma). + // Rec. 601 weights, as After Effects (effects crate luma_601). let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index a0abcd22c..11dc81a93 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -852,7 +852,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } /// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB -/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::tint_luma`). +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). #[test] fn gpu_tint_defaults_match_after_effects_greys() { let Some(g) = gpu() else { return }; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index cf2c6bb7b..9fce10665 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint has Swap Colors (#963) and matches After Effects 2026 renders of primary and grey solids (Rec. 601 luminance); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint and Tritone match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From 70c1349cbcc36a4bada8d0f37c1b9bae794ba086 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:40:14 +0200 Subject: [PATCH 0160/1386] Black & White: Tint keeps the gray's luminosity, as After Effects After Effects tints Black & White's gray like a Color blend: the tint colour's hue and saturation at the gray's luminosity (weights 0.3 / 0.59 / 0.11, channels outside 0..1 pulled back toward the gray). EffectCraft used the gray as HSL lightness. Measured in AE 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) AE (43, 21, 0), EffectCraft was (51, 26, 0), now (43, 21, 0); a yellow solid at the default sliders (gray 0.6, case 825) AE (255, 129, 2), now (255, 129, 3); white stays white. The six sliders already matched AE on eight colours (cases 816, 817). CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 61 +++++++++++++++++++++++----- crates/gpu/src/fx_color.rs | 3 +- crates/gpu/src/shaders/fx_color.wgsl | 28 ++++++++++++- docs/effects-parity.md | 3 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0040293e0..e93026df0 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -343,6 +343,33 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted /// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright /// and black blocks (#1463), and 32-bpc overbrights divided by zero. +/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a +/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled +/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid +/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), +/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { + let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; + if !gray.is_finite() || gray <= 0.0 { + return [0.0; 3]; + } + if gray >= 1.0 { + return [gray; 3]; + } + let d = gray - lum(tint); + let c = tint.map(|v| v + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut o = c; + if n < 0.0 && l - n > 1e-9 { + o = o.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 1e-9 { + o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + o +} + fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); @@ -357,13 +384,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); let gray = (min + (max - min) * wt).max(0.0); - if tint_on { - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); - let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); - [r, g, bl] - } else { - [gray, gray, gray] - } + if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -1248,9 +1269,6 @@ mod tests { assert!(go(0.0, 100.0, 64) >= 30); } - /// #1463: Black & White's sliders set the gray of each pure hue and blend between - /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright - /// colour stays finite (it used to divide by zero). /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), @@ -1274,6 +1292,29 @@ mod tests { } } + /// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow + /// and white solids at the default sliders (case 825). + #[test] + fn black_white_tint_matches_after_effects() { + let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5); + let mut reds = tint.to_vec(); + reds.push(("reds", num(10.0))); + let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]); + assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]); + assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0)); + // Hostile grays stay finite. + for g in [f32::NAN, -1.0, 0.0, 4.0] { + assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite())); + } + } + + /// #1463: Black & White's sliders set the gray of each pure hue and blend between + /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright + /// colour stays finite (it used to divide by zero). #[test] fn black_white_sliders_set_the_gray_of_each_hue() { let bw = |vals: &[(&str, Value)], c: [f32; 3]| px("ec.color.blackwhite", vals, [c[0], c[1], c[2], 1.0]); diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index b9ae5282c..18bd8409f 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.color.blackwhite" => { let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); let tc = pr.color("tintColor"); - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); p.u[0] = [3, pr.b("tint") as u32, 0, 0]; p.f[0] = [w[0], w[1], w[2], w[3]]; p.f[1] = [w[4], w[5], 0.0, 0.0]; - p.f[2] = [th, ts, 0.0, 0.0]; + p.f[2] = [tc[0], tc[1], tc[2], 0.0]; } "ec.color.tritone" => { p.u[0][0] = 4; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 38325a944..daac9fb5a 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -100,11 +100,35 @@ fn fxc_sc_weights(c0: vec3) -> array { return w; } +// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the +// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { + if (!(gray > 0.0)) { + return vec3(0.0); + } + if (gray >= 1.0) { + return vec3(gray); + } + let w = vec3(0.3, 0.59, 0.11); + let c = tint + vec3(gray - dot(tint, w)); + let l = dot(c, w); + let n = min(min(c.x, c.y), c.z); + let x = max(max(c.x, c.y), c.z); + var o = c; + if (n < 0.0 && l - n > 1e-9) { + o = vec3(l) + (o - vec3(l)) * l / (l - n); + } + if (x > 1.0 && x - l > 1e-9) { + o = vec3(l) + (o - vec3(l)) * (1.0 - l) / (x - l); + } + return o; +} + // u[0].x = op: // 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity // 2 Vibrance f[0] = (vibrance, saturation) // 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); -// u[0].y = tint; f[2] = (tint hue, tint saturation) +// u[0].y = tint; f[2] = tint colour // 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend // 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) // 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome @@ -159,7 +183,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(gray); if (P.u[0].y != 0u) { - o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + o = fxc_bw_tint(P.f[2].xyz, gray); } px = vec4(o * a, a); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 9fce10665..2b8b72e14 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint and Tritone match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -65,6 +65,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | +| 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From c7060e2d759cdab8436d1e2806472f292a61b0ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:15:06 +0200 Subject: [PATCH 0161/1386] Exposure: gamma 2.2 linear-light conversion, as After Effects With Bypass Linear Light Conversion off, Exposure decoded and re-encoded with the piecewise sRGB curve. After Effects 2026 renders (sRGB working space, 32 bpc) of Offset 0.0005 / 0.002 / 0.02 / 0.1 on black measure 8 / 15 / 43 / 90, which a pure 2.2 power predicts (8.06 / 15.13 / 43.08 / 89.54) and the sRGB curve does not (1.65 / 6.59 / 38.68 / 89.04). The effect now uses its own 2.2 power curve on the CPU and in the GPU kernel; the shared colour functions and other effects are unchanged. As before, the conversion applies whatever the working space, also a linearised one. Negative values go through both curves mirrored (input was clamped at 0 before), so defaults are an identity for 32-bpc values and a negative Offset darkens past black without NaN. New test against the AE values; existing Exposure tests still hold. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 43 +++++++++++++++++++++++++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++-- docs/effects-parity.md | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index e93026df0..3556fbe59 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -314,6 +314,17 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { } } +/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion +/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 +/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; + +/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless +/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve +/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it +/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a +/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past +/// black and the default settings leave every value unchanged. fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = exposure_settings(ctx); let bypass = ctx.params.b("bypassLinearLight"); @@ -323,9 +334,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { if bypass { return mirror_pow(c[i] * e + off, 1.0 / g); } - let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0)); + let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA); let o = mirror_pow(lin * e + off, 1.0 / g); - effectcraft_color::linear_to_srgb(o.abs()).copysign(o) + mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA) }) }); b @@ -1406,6 +1417,34 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, + /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. + /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. + #[test] + fn exposure_matches_after_effects_gamma_2_2() { + let red = [1.0, 0.0, 0.0, 1.0]; + let code = |o: [f32; 4], i: usize| o[i] * 255.0; + // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). + for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { + let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); + for i in [1, 2] { + assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); + } + assert!(o[0] >= 1.0, "{o:?}"); + } + // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); + assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); + assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + // Default settings leave 32-bpc values alone, negative and over-range ones included. + let hdr = [-0.25, 0.5, 2.0, 1.0]; + assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); + // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. + let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); + assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); + } + #[test] fn change_to_color_change_modes_tolerances_and_matte() { let red = [0.8, 0.1, 0.1, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d0e8bf2..870430a5b 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -714,14 +714,15 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = straight(px); var o = vec3(0.0); for (var i = 0; i < 3; i++) { - // Per-channel gain, offset, gamma; negative values are mirrored. + // Per-channel gain, offset, gamma; negative values are mirrored. The + // linear-light conversion is a pure 2.2 power (effects crate exposure). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); } else { - let lin = srgb_to_linear(max(c[i], 0.0)); + let lin = sign(c[i]) * powz(abs(c[i]), 2.2); let x = lin * P.f[0][i] + P.f[1][i]; - o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i])); + o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2); } } px = vec4(o * a, a); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2b8b72e14..88b42d627 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -67,6 +67,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | +| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 617b0a578d9b16333483cf82c834d9294352404e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:22:38 +0200 Subject: [PATCH 0162/1386] Exposure: After Effects parameter ranges All twelve sliders (Master, Red, Green, Blue) take After Effects' ranges: Exposure valid -100..100, slider -4..4, 2 decimals (was -20..20, slider -5..5); Offset -2..2, slider -0.5..0.5, 4 decimals (unchanged); Gamma Correction valid and slider 0.1..10, 2 decimals (was 0.01..9.99, slider 0.1..3). Every value inside the old Exposure and Offset ranges stays valid. Saved values are not clamped on load, so a gamma below 0.1 from an older project or a Photoshop import (0.01..9.99) is kept and still renders (the renderer's 0.01 floor is unchanged); only new edits clamp to 0.1. Test of the ranges and of an old gamma surviving the load refresh. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 38 ++++++++++++++++++++++++++++++++-- docs/effects-parity.md | 1 + 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 3556fbe59..5a00114fc 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -1011,11 +1011,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1417,6 +1419,38 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma + /// of 0.05, valid before, as in Photoshop) survives loading and still renders. + #[test] + fn exposure_has_after_effects_ranges_and_keeps_old_values() { + use effectcraft_project::build::Ids; + let spec = crate::find("ec.color.exposure").unwrap(); + let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; + let sliders: Vec<_> = spec + .params + .iter() + .filter_map(|p| match p.ui { + ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), + _ => None, + }) + .collect(); + assert_eq!(sliders.len(), 12); + for (i, (id, r)) in sliders.iter().enumerate() { + assert_eq!(*r, want[i % 3], "{id}"); + } + let mut next = 1; + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + gamma.value = num(0.05); + gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + assert_eq!(gamma.value, num(0.05)); + assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); + let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); + assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); + } + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 88b42d627..f83258759 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | +| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 515cc3d5f711805982e4fd9dbe1586cd1dfc42eb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:26:09 +0200 Subject: [PATCH 0163/1386] Levels: channel controls before RGB, "Off for 32 bpc Color" unclipped in 32 bpc, as After Effects After Effects 2026 in a 32 bpc project, Levels (Individual Controls) with RGB (0.1, 0.9, gamma 1.5, 0.05, 0.95), Red Gamma 0.8, Green Input White 0.8 and Blue Output Black 0.2 on grey steps 0..255 (case 904 E07): every step matches "each channel's controls, then the RGB controls" with nothing clipped (values below Input Black end below Output Black, above Input White above Output White). EffectCraft ran RGB first and clipped "Off for 32 bpc Color" (it could not see the bit depth): up to 27/255 off on 95 % of the grid; now 0 grid points off, max 1/255, CPU and GPU. EffectEnv gains float_depth (the renderer sets it for 32 bpc projects), so "Off for 32 bpc Color" clips in 8 / 16 bpc projects as before. Unclipped values below 0 take the gamma mirrored through 0. Plain Levels keeps its order (its channel controls were not measured); its clipping follows the same rule. EffectCraft's own projects default to 8 bpc and are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 64 ++++++++++++++++++++++++++++- crates/effects/src/color_fx.rs | 27 +++++++++--- crates/effects/src/lib.rs | 2 + crates/gpu/src/deferred.rs | 1 + crates/gpu/src/shaders/fx_tone.wgsl | 15 +++++-- crates/gpu/src/shaders/kernels.wgsl | 7 +++- crates/render/src/lib.rs | 1 + docs/effects-parity.md | 3 +- 8 files changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 71dee87b4..d78b0fb08 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -201,7 +201,7 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01)) + self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) } } @@ -217,7 +217,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let clip = crate::color_fx::levels_clip(ctx); - map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); + // Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB + // (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green + // 52/255 (RGB first would give 45). + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); b } @@ -1139,6 +1142,63 @@ mod tests { assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); } + /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, + /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green + /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: + /// the channel controls run first, nothing clips (8-bit captures clamp the results). + #[test] + fn levels_ic_matches_after_effects_in_32_bpc() { + let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let ae = [ + [0, 0, 70], + [0, 0, 88], + [0, 52, 104], + [61, 108, 132], + [102, 150, 158], + [138, 187, 181], + [171, 220, 203], + [203, 250, 223], + [233, 255, 243], + [255, 255, 255], + ]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [ + ("rgb/rgbInBlack", Value::Scalar(0.1)), + ("rgb/rgbInWhite", Value::Scalar(0.9)), + ("rgb/rgbGamma", Value::Scalar(1.5)), + ("rgb/rgbOutBlack", Value::Scalar(0.05)), + ("rgb/rgbOutWhite", Value::Scalar(0.95)), + ("red/redGamma", Value::Scalar(0.8)), + ("green/greenInWhite", Value::Scalar(0.8)), + ("blue/blueOutBlack", Value::Scalar(0.2)), + ]; + let s = find("ec.color.levelsic").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &vals { + params.values.insert(k.to_string(), v.clone()); + } + let render = |float_depth: bool| { + let env = crate::EffectEnv { float_depth, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let out = render(true); + for (x, want) in ae.iter().enumerate() { + let p = out.get(x as i64, 0); + for c in 0..3 { + let got = p[c].clamp(0.0, 1.0) * 255.0; + assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); + } + } + // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. + let clipped = render(false); + assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 5a00114fc..705cc3c7c 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -207,16 +207,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"]; /// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips -/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch -/// of projects saved before the two popups existed turns both off. +/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual +/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden +/// `noClip` switch of projects saved before the two popups existed turns both off. pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { if ctx.params.b("noClip") { return (false, false); } - let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0); + let on = |id: &str| levels_clip_on(ctx, id); (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip). +pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool { + match ctx.params.get(id).map(|v| v.as_enum()) { + Some(0) => false, + Some(2) => !ctx.env.float_depth, + _ => true, + } +} + +/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate +/// (the curve mirrored through 0) instead of stopping at Output Black. +pub fn levels_gamma(t: f32, g: f32) -> f32 { + if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) } +} + /// Levels' Channel popup. pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; @@ -242,8 +258,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) if clip_w { t = t.min(1.0); } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t + ob + (ow - ob) * levels_gamma(t, g) } const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; @@ -286,7 +301,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] let ids = levels_channel_ids(i + 1); let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); let pre = LEVELS_CHANNEL_PREFIX[i]; - let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let on = |id: String| levels_clip_on(ctx, &id); let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) }) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..f157e74c2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -406,6 +406,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e6..e96b54a1d 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.u64(e.effect_index as u64); k.debug(&e.working_space); k.u64(e.working_linear as u64); + k.u64(e.float_depth as u64); k.debug(&e.shutter); } k.finish() diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 39db34588..c99d199c5 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 { } // Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +// color_fx::levels_gamma: unclipped values below 0 mirror the curve. +fn fxt_gamma(t: f32, g: f32) -> f32 { + if (t >= 0.0) { + return powz(t, 1.0 / g); + } + return -powz(-t, 1.0 / g); +} + fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { let o = i * 6u; if (data[o + 5u] != 0.0) { @@ -32,7 +40,7 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { if (clip_w) { t = min(t, 1.0); } - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). @@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 { if (data[o + 6u] != 0.0) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / data[o + 2u]); - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]); } fn fxt_level_ident(o: u32) -> bool { @@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let aa = max(a, 0.0); var nc: vec3; for (var i = 0u; i < 3u; i++) { - nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw); } let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); o = vec4(nc * na, na); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 870430a5b..00d75fe25 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -703,7 +703,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].z != 0u) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / g); + // color_fx::levels_gamma: unclipped values below 0 mirror the curve. + if (t >= 0.0) { + t = powz(t, 1.0 / g); + } else { + t = -powz(-t, 1.0 / g); + } o[i] = ob + (ow - ob) * t; } px = vec4(o * a, a); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..dff28f22d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1184,6 +1184,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + float_depth: self.pipe.levels.is_none(), shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f83258759..2d59c6b41 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -66,6 +66,7 @@ the GPU is performance, not parity. |---|---|---| | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | +| 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | From dc9f15ada114bc3be659650db08cac4e584f44cb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:41:24 +0200 Subject: [PATCH 0164/1386] Brightness & Contrast: negative settings invert the positive curves, as After Effects In After Effects 2026 the non-legacy curve of Brightness -40 is the inverse of Brightness +40 (within 2.6/255 on a full black-to-white ramp) and Contrast -50 the inverse of Contrast +50. EffectCraft fitted the negative brightness with its own power (2^(1.32 |b|) on v), 20/255 off AE at -40; the negatives now use the reciprocal exponents of the positive curves: Brightness -40 max 6.2/255 (was 19.8), Contrast -50 4.1 (was 7.0); positives unchanged (40: 5.5, 80: 7.8, Contrast 50: 2.7, 90: 5.0; B20 C30: 6.6, which confirms brightness before contrast). The #681 oracle samples still pass. CPU and GPU. The positive curves' remaining 5-8/255 needs a better curve family (a two-point spline fits each capture within 1.6/255), left open. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 40 +++++++++++++++++++++++++---- crates/gpu/src/shaders/kernels.wgsl | 16 ++++++++---- docs/effects-parity.md | 4 +-- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 705cc3c7c..4dd800fa8 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -59,18 +59,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { /// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of /// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 /// pass through. The GPU kernel mirrors it. +/// +/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects +/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise), +/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } let mut v = v.max(0.0); - if b > 0.0 { - v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); - } else if b < 0.0 { - v = v.powf(2f32.powf(-1.32 * b)); + if b != 0.0 { + let e = 2f32.powf(1.24 * b.abs()); + v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e }); } if c != 0.0 { - let g = (1.0 + 0.53 * c).max(0.05); + let g = (1.0 + 0.53 * c.abs()).max(0.05); + let g = if c > 0.0 { g } else { 1.0 / g }; v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; } v @@ -1340,6 +1344,32 @@ mod tests { } } + /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast + /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) + #[test] + fn negative_brightness_and_contrast_invert_the_positive_curves() { + let go = |b: f64, c: f64, x: u8| { + let v = x as f32 / 255.0; + (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 + }; + let samples: &[(f64, f64, u8, u8)] = &[ + (-40.0, 0.0, 64, 50), + (-40.0, 0.0, 128, 99), + (-40.0, 0.0, 191, 155), + (-40.0, 0.0, 234, 207), + (0.0, -50.0, 21, 28), + (0.0, -50.0, 64, 76), + (0.0, -50.0, 191, 179), + (0.0, -50.0, 234, 227), + (40.0, 0.0, 128, 162), + (0.0, 50.0, 64, 52), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + } + /// #1463: Black & White's sliders set the gray of each pure hue and blend between /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright /// colour stays finite (it used to divide by zero). diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 00d75fe25..45cb9efe2 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -624,14 +624,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y == 2u) { // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let g = max(1.0 + 0.53 * ct, 0.05); + // Negative settings invert the positive curves (reciprocal exponents). + var g = max(1.0 + 0.53 * abs(ct), 0.05); + if (ct < 0.0) { + g = 1.0 / g; + } + var e = exp2(1.24 * abs(br)); + if (br < 0.0) { + e = 1.0 / e; + } for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { var v = max(c[i], 0.0); - if (br > 0.0) { - v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); - } else if (br < 0.0) { - v = powz(v, exp2(-1.32 * br)); + if (br != 0.0) { + v = 1.0 - powz(1.0 - v, e); } if (ct != 0.0) { if (v < 0.5) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2d59c6b41..d072c49c1 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -67,8 +67,8 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | -| 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From 39bd0efac29b5e656c59ca969b5abcddb6538b5e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:26:04 +0200 Subject: [PATCH 0165/1386] Luma Key: Key Out Brighter and Darker cut hard at Threshold, as After Effects After Effects' Luma Key removes everything darker (or brighter) than Threshold and ignores Tolerance for those two key types; Tolerance only sets the band of Key Out Similar / Dissimilar. EffectCraft ramped the matte over Tolerance, so case 719 (a 50 % grey, Key Out Darker, Threshold 140, Tolerance 20) kept about 80 % of the layer where AE keys it out completely. Measured in AE 2026 on a vertical grey ramp (cases 812-814): Darker keys up to level 139.9 and keeps 140.1 (EffectCraft now the same row); Similar keys levels 120.3-160.0; with Edge Thin 1 / Edge Feather 2.5 the matte rises over rows 591-596 (EffectCraft within 0.2 of AE's alpha there). CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/keying.rs | 93 +++++++++++++++++++----------- crates/gpu/src/shaders/fx_key.wgsl | 18 ++---- docs/effects-parity.md | 3 +- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c8..c4f216aa2 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -135,46 +135,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Luma Key ---- +/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and +/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar +/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather +/// then soften the cut. fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("keyType"); let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { let l = luminance(c[0], c[1], c[2]); - match kind { - 0 => { - if tol > 0.0 { - ((thr + tol - l) / tol).clamp(0.0, 1.0) - } else if l > thr { - 0.0 - } else { - 1.0 - } - } - 2 => { - if (l - thr).abs() <= tol { - 0.0 - } else { - 1.0 - } - } - 3 => { - if (l - thr).abs() <= tol { - 1.0 - } else { - 0.0 - } - } - _ => { - if tol > 0.0 { - ((l - (thr - tol)) / tol).clamp(0.0, 1.0) - } else if l < thr { - 0.0 - } else { - 1.0 - } - } - } + let keep = match kind { + 0 => l <= thr, + 2 => (l - thr).abs() > tol, + 3 => (l - thr).abs() <= tol, + _ => l >= thr, + }; + if keep { 1.0 } else { 0.0 } }); let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); apply_matte(&mut b.img, &m, 0); @@ -802,6 +779,54 @@ mod tests { assert_eq!(out.get(1, 0)[3], 1.0); } + /// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y + /// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the + /// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it + /// out). + #[test] + fn luma_key_matches_after_effects() { + let ramp = || { + let mut img = Image::new(1, 1080); + for y in 0..1080 { + let v = (y as f32 + 0.5) / 1080.0; + img.set(0, y, [v, v, v, 1.0]); + } + img + }; + let key = |kind: u32, thin: f64, feather: f64| { + let set = + [("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))]; + let o = run("ec.key.luma", &set, ramp()); + (0..1080).map(|y| o.get(0, y)[3]).collect::>() + }; + // Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593. + let d = key(1, 0.0, 0.0); + assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]); + // Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the + // rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way. + let s = key(2, 0.0, 0.0); + assert!( + (0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)), + "{:?} {:?}", + &s[505..512], + &s[674..681] + ); + // Key Out Brighter and Dissimilar are the complements. + let b = key(0, 0.0, 0.0); + assert!(b[595] == 0.0 && b[590] == 1.0); + let ds = key(3, 0.0, 0.0); + assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0); + // A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719). + let o = + run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0])); + assert_eq!(o.get(0, 0)[3], 0.0); + // Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596. + let f = key(1, 1.0, 2.5); + for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] { + assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index a28ecc65c..b38654729 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -199,23 +199,13 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { let thr = f0.x; let tol = f0.y; var m: f32; + // As effects::keying::luma_key: Brighter / Darker cut hard at the threshold + // (Tolerance only shapes Similar / Dissimilar), as After Effects. switch u0.y { - case 0u: { - if (tol > 0.0) { - m = clamp((thr + tol - l) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l > thr); - } - } + case 0u: { m = select(1.0, 0.0, l > thr); } case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } - default: { - if (tol > 0.0) { - m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l < thr); - } - } + default: { m = select(1.0, 0.0, l < thr); } } o = vec4(m); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d072c49c1..1fcbc4a63 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -38,7 +38,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | | Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | -| Obsolete | 9 | 9 | — | +| Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) | | Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | | Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | | Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | +| 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From 60dab98e9b1052efc454a8a8abf4a61031130446 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:15:35 +0400 Subject: [PATCH 0166/1386] i18n: saved workspace names stay verbatim in the workspace menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the built-in workspaces read the Window ▸ Workspace rows; a name the user saved (\"Composition\", say) no longer falls through to the panel catalog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/header.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index acbd2fdcb..c1272c41d 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -639,9 +639,15 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter r.max.x + 16.0 } +/// A workspace's drawn name: built-ins read the menu bar's Window ▸ Workspace row; names the user +/// saved stay verbatim, even when one matches a catalog source such as "Composition". +fn workspace_label(name: &str) -> &str { + if crate::dock::WORKSPACES.contains(&name) { crate::i18n::command_label("window.workspace", name) } else { name } +} + fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { for w in app.workspace_names() { - if ui.selectable_label(app.ui.workspace == w, crate::i18n::command_label("window.workspace", &w)).clicked() { + if ui.selectable_label(app.ui.workspace == w, workspace_label(&w)).clicked() { app.set_workspace(&w); ui.close(); } @@ -692,6 +698,15 @@ mod tests { out.textures_delta.clear(); } + #[test] + fn saved_workspace_names_stay_verbatim() { + crate::i18n::set_current("zh-hans"); + assert_ne!(workspace_label("Default"), "Default"); + assert_ne!(crate::i18n::tr("Composition"), "Composition"); + assert_eq!(workspace_label("Composition"), "Composition"); + crate::i18n::set_current("en"); + } + #[test] fn long_press_type_keeps_the_tool_until_vertical_is_selected() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From ad8db289cfed2eff2825d3fce695546200c8d855 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:15:35 +0400 Subject: [PATCH 0167/1386] i18n: Settings page titles read the menu bar's Settings rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog's \"Type\" is the kind (Camera Settings, the Project column), so the Type page drew as \"kind\" in Chinese; its title now comes from the app.settings menu row (文字), as the Settings submenu draws it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/settings.rs | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 702c6ffd1..dde67e225 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -173,6 +173,14 @@ pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str { row(source).or_else(|| row(dotted.as_deref()?)).unwrap_or_else(|| tr(source)) } +/// A Settings page's title as the menu bar's Settings submenu draws it, without the ellipsis; the +/// panel catalog's row for pages that have no menu row. The panel catalog's "Type" is the kind +/// (Camera Settings, the Project panel's column), while the Type page is about text. +pub(crate) fn settings_page_title(title: &str) -> &str { + let menu = menu_catalog(ui::current()).and_then(|c| c.iter().find(|(id, en, _)| *id == "app.settings" && en.strip_suffix("...") == Some(title))); + menu.and_then(|(_, _, translated)| translated.strip_suffix("...")).unwrap_or_else(|| tr(title)) +} + /// `source` looked up in `catalog` by (command, English label); the source itself when the pair is /// absent, so computed labels and user names stay verbatim. fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str { @@ -6691,6 +6699,16 @@ mod tests { assert_eq!(command_label("window.panel", "Project"), "Project"); } + #[test] + fn settings_page_titles_read_the_menu_row() { + set_current("zh-hans"); + assert_eq!(settings_page_title("Type"), "文字"); + assert_eq!(tr("Type"), "类型"); + assert_eq!(settings_page_title("Roto Brush"), tr("Roto Brush")); + set_current("en"); + assert_eq!(settings_page_title("Type"), "Type"); + } + #[test] fn match_system_takes_the_first_browser_language_with_a_catalog() { let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 28a7c0142..6954e1377 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -677,7 +677,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.painter().text( r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, - tr(p.title), + crate::i18n::settings_page_title(p.title), Tokens::ui(12.5), if sel { Color32::WHITE } else { t.text }, ); @@ -694,7 +694,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // dialog scrolled sideways a little (#284). ui.set_width(ui.available_width()); let p = &all[idx]; - ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); + ui.label(RichText::new(crate::i18n::settings_page_title(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); ui.add_space(6.0); egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| { page_ui(app, ui, p, &cur, t, &mut acts); From 75271bfe6c9fb21b44de1cf0c4c66974639be8bb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:20:28 +0200 Subject: [PATCH 0168/1386] Luma Key and Extract: Rec. 601 luminance, as After Effects After Effects 2026 keys Luma Key and Extract (Channel Luminance) on Rec. 601 weights of the working-space values: Key Out Darker / Extract Black Point at 65, 166 and 24 on pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green (150 / 182) - Rec. 709, which EffectCraft used, decides all three the other way. Case 904 E18 (Extract 30 / 220 / softness 10 on grey and colour steps): 513 of 5184 grid points off (pure blue kept at 231 in AE, keyed out in EC), now 0, max 0. CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 15 ++++++++------- crates/effects/src/keying.rs | 21 ++++++++++++++++++--- crates/gpu/src/shaders/fx_key.wgsl | 9 +++++++-- docs/effects-parity.md | 3 ++- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 4dd800fa8..ddbb61b29 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,13 +14,14 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } -/// Rec. 601 luminance on the working-space values, as After Effects 2026 renders Tint, Tritone -/// and the other Rec. 601 effects (sRGB working space, not linearised: red 76/255, green -/// 150/255, blue 29/255). Tint and Tritone use it (Tritone, case 718: a red solid with -/// Blend With Original 25 gives (132, 23, 46), from luminance 0.299; Rec. 709's 0.213 gave -/// (113, 16, 49); on a grey ramp both weightings agree, case 815). The GPU kernels -/// (`pointwise` op 1, `fx_color` op 4) mirror it. -fn luma_601(c: [f32; 3]) -> f32 { +/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red +/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and +/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from +/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree +/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on +/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green +/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. +pub(crate) fn luma_601(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index c4f216aa2..4953d482c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -144,7 +145,7 @@ fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { - let l = luminance(c[0], c[1], c[2]); + let l = luma_601([c[0], c[1], c[2]]); let keep = match kind { 0 => l <= thr, 2 => (l - thr).abs() > tol, @@ -311,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -827,6 +828,20 @@ mod tests { } } + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index b38654729..37146c329 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,6 +62,11 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. +// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +fn fxk_key_luma(c: vec3) -> f32 { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); t = powz(t, 1.0 / max(gamma, 0.01)); @@ -195,7 +200,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(select(1.0, 0.0, d <= f0.w)); } case 2u: { - let l = luminance(c); + let l = fxk_key_luma(c); let thr = f0.x; let tol = f0.y; var m: f32; @@ -231,7 +236,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { case 2u: { v = c.y; } case 3u: { v = c.z; } case 4u: { v = a; } - default: { v = luminance(c); } + default: { v = fxk_key_luma(c); } } v = 255.0 * v; let bp = f0.x; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 1fcbc4a63..92f4d419d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -35,7 +35,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | -| Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | +| Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | | Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | | Obsolete | 9 | 9 | Luma Key matches After Effects 2026 on a grey ramp (hard cut, Tolerance only for Similar / Dissimilar) | @@ -69,6 +69,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | +| 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From d7854b84a31ff3be6cd3f57291a68ac44f289695 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:37:28 +0200 Subject: [PATCH 0169/1386] =?UTF-8?q?4-Color=20Gradient:=20weights=201=20/?= =?UTF-8?q?=20(d=C2=B2=20+=20Blend=C2=B2),=20as=20After=20Effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Effects weighs each of the four points by its inverse squared distance softened by Blend squared, 1 / (d^2 + Blend^2) in pixels: fitted to AE 2026 renders of the default gradient at Blend 10, 100 and 300 and case 904 E16 at Blend 50 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 = about Blend^2; mean error 0.27/255, max 0.9). EffectCraft used 1 / (d^2 / (100 Blend))^1.5, in which Blend cancels out: E16 was 34/255 off on 99 % of the grid, now 0 grid points off, max 1/255, CPU and GPU. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate.rs | 66 ++++++++++++++++++++++++- crates/effects/src/lib.rs | 2 +- crates/gpu/src/fx_generate.rs | 4 +- crates/gpu/src/shaders/fx_generate.wgsl | 2 +- docs/effects-parity.md | 3 +- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index fd5a54123..637cc428e 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index f157e74c2..13a4130d0 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -91,7 +91,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4fa..0216d3907 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } - let blend = ctx.params.f("blend").max(1.0); + let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; - p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) } diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0ba..bca00ce1b 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let dx = f32(p.x) + 0.5 - q.x; let dy = f32(p.y) + 0.5 - q.y; let d2 = dx * dx + dy * dy; - let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + let w = 1.0 / (d2 + bl); acc += P.f[4 + i].xyz * w; wsum += w; } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 92f4d419d..97c43bc43 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | -| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -70,6 +70,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From 84ffc8453eedbc509d226e483e63221efb9f7324 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 10:42:33 +0200 Subject: [PATCH 0170/1386] Fill: Opacity is the filled layer's opacity, as After Effects Without a Fill Mask, After Effects turns the layer into the fill colour and applies Opacity to its alpha; nothing of the original shows through. AE 2026 renders Fill (0, 0.5, 1) at Opacity 50 % on a Gradient Ramp over a red solid as premultiplied (0, 0.251, 0.502) at alpha 0.502 at every pixel; EffectCraft mixed the fill with the ramp (mean difference 95.5/255), now within 1/255, CPU and GPU. Invert fills the transparent part the same way. With a Fill Mask the mix with the original is unchanged (not measured). Lottie's fill effect (colour, alpha x opacity) now plays the same in EffectCraft. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 37 ++++++++++++++++++++++------- crates/gpu/src/shaders/kernels.wgsl | 10 +++----- docs/effects-parity.md | 3 ++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index ddbb61b29..8e451f4eb 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -480,15 +480,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -1666,6 +1663,30 @@ mod tests { assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); } + /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp + /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the + /// fill colour at half the layer's alpha, nothing of the ramp left. + #[test] + fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { + let mut img = Image::new(16, 4); + for x in 0..16 { + let v = x as f32 / 15.0; + for y in 0..4 { + img.set(x, y, [v, 0.0, 0.0, 1.0]); + } + } + let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; + let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; + for x in [0, 7, 15] { + let p = out.get(x, 2); + let ae = [0.0, 0.251, 0.502, 0.502]; + assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); + } + // A half-transparent pixel ends at a quarter alpha, still the fill colour. + let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; + assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); + } + #[test] fn colorama_palettes_modify_selection_masking_and_add_phase() { let grey = [0.5, 0.5, 0.5, 1.0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 45cb9efe2..2d00ccfa9 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -766,17 +766,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = P.f[0]; let invert = P.u[0].y != 0u; let opacity = P.f[1].x; + // The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill). var fa = px.w; if (invert) { fa = 1.0 - px.w; } - let f = c.xyz * fa; - let rgb = px.xyz + (f - px.xyz) * opacity; - var alpha = px.w; - if (invert) { - alpha = px.w + (fa - px.w) * opacity; - } - px = vec4(rgb, alpha); + fa = fa * opacity; + px = vec4(c.xyz * fa, fa); } case 8u: { // effects::color2::map_ca: transparent pixels only change through the alpha curve. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 97c43bc43..4e9a2ddb7 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -33,7 +33,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | -| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | +| Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Luma Key and Extract key on Rec. 601 luminance, as After Effects 2026; Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | @@ -71,6 +71,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | +| 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From dd29665939553cc453ab3c742a19375cd2482848 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:16:21 +0400 Subject: [PATCH 0171/1386] text: the Georgian fallback test goes through fallback_for system_fallback is the scan-order search; fallback_for is where the Georgian families are preferred, so the test asks it for Mkhedruli in Inter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 3be0f1085..354d71f30 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1152,9 +1152,9 @@ mod tests { } } - /// Native: the script search picks a Georgian family for Mkhedruli (a face already loaded - /// that happens to cover it, such as macOS Helvetica, still wins in `fallback_for`). Skipped - /// where no family from the list is installed. + /// Native: Mkhedruli in a Latin-only family falls back to a family from the Georgian list, + /// ahead of faces already loaded and of the scan order. Skipped where no family from the list + /// is installed. #[test] fn georgian_falls_back_to_a_georgian_system_family() { scan_system(); @@ -1163,7 +1163,7 @@ mod tests { eprintln!("no Georgian family from the list is installed; skipping"); return; } - let f = face(system_fallback(c, "Regular").unwrap()); + let f = face(fallback_for(c, resolve("Inter", "Regular").face)); assert!(f.has_char(c), "{} lacks it", f.info.family); assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family); } From 2447b54856ad27bcb3f4564ce4e58ec2addc461a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:16:21 +0400 Subject: [PATCH 0172/1386] docs: Georgian catalog counts at this head Menus 628 rows (every menu catalog), panels 886 (695 shared with the Chinese catalogs plus 191 Georgian-only), effect names 307 + 21 categories; gaps.md and localization-parity.md now agree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- docs/gaps.md | 2 +- docs/localization-parity.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 8c8ba84c0..f8fcd64c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 624 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 701850477..f074c5e55 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -26,7 +26,7 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); counted at 470ea7a3; Georgian has every row the Chinese catalogs have. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese and Georgian (306 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes @@ -83,7 +83,7 @@ Other languages EffectCraft ships (4): | Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | | Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,510 (≈ 38%): menus 628 rows, panels 882 (the Chinese catalogs' 687 plus 195 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -105,7 +105,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | -| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 882 (195 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | +| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | From f4fbdc6c250898ea724b4f8f5a4885f70318d070 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:10:56 +0200 Subject: [PATCH 0173/1386] Glow: the glow covers the layer's blurred alpha, blur sigma 0.384 x radius, as After Effects With Glow Based On Color Channels, After Effects gives the glow the layer's whole alpha (blurred), not the thresholded part, and Intensity scales only its colour: a layer below the threshold darkens what lies under its edge. Fitted to AE 2026 renders of a grey solid over red (cases 524, 807-809): the halo is the same at Intensity 1 and 3 and with Composite Original Behind or On Top, and its edge is an erfc step of sigma = 0.384 x Glow Radius at radius 10 and 30 (was radius / 2). 524 (Glow defaults on a 50 % grey solid): EffectCraft had no halo (max 116/255 off at the edge); now red 248/234/208/165/140 at 7.5/5.5/3.5/1.5/0.5 px outside vs AE 249/235/206/164/140, max 2/255 over the frame, CPU and GPU. The blur width applies to every glow; alpha-based glows keep their coverage rule (thresholded alpha, scaled by Intensity). Not matched yet: the brightness of glows above the threshold (AE about 3.7 x brighter for a white solid, cases 806, 810, 811). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/misc.rs | 53 +++++++++++++++++++++++--- crates/gpu/src/effects.rs | 3 +- crates/gpu/src/fx_stylize.rs | 3 +- crates/gpu/src/shaders/fx_stylize.wgsl | 16 ++++++-- crates/gpu/src/shaders/kernels.wgsl | 18 +++++++-- docs/effects-parity.md | 3 +- 7 files changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..89be21ddb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..58e2b2c55 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -120,6 +120,13 @@ pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow's blur: a Gaussian of σ = 0.384 × Glow Radius. Fitted to After Effects 2026 (cases +/// 807 and 808, Glow Radius 10 and 30 on a grey solid): the edge profile of the glow's +/// coverage is an erfc step of that σ within 1.7/255 (it was σ = radius / 2). +pub fn glow_sigma(radius: f64) -> f64 { + (radius * 0.384).max(0.5) +} + /// Glow Operation options (blend modes the glow is applied with). pub const GLOW_OPERATIONS: [&str; 17] = [ "Normal", @@ -183,23 +190,27 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); + // Based on the colour channels, the glow's coverage is the layer's whole alpha, below + // the threshold too: After Effects darkens what lies under a dim layer's edge (glow_alpha). + let ga = if alpha_based { k * a } else { a }; if let Some(map) = &arbitrary { let t = glow_ab_t(l, looping, loops, phase, mid); let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; - *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; } else { - *px = px.map(|v| v * k); + *px = [px[0] * k, px[1] * k, px[2] * k, ga]; } }); - let s = (radius / 2.0).max(0.5); + let s = glow_sigma(radius); let blurred = gaussian_blur(&bright, s * kx, s * ky, false); let mode = ctx.params.e("operation"); b.img.data.par_iter_mut().zip(blurred.data.par_iter()).for_each(|(o, g)| { - let g = g.map(|v| v * intensity); + // Intensity scales the glow's colour; with the colour channels its coverage stays. + let g = [g[0] * intensity, g[1] * intensity, g[2] * intensity, if alpha_based { g[3] * intensity } else { g[3] }]; match mode { // Behind: the glow shows only where the original is transparent. 1 => { @@ -749,6 +760,38 @@ mod tests { img } + /// After Effects 2026 (cases 524, 807, 808, 809): Glow on a 50 % grey solid, which is below + /// the 60 % threshold, still covers a blurred copy of the layer's alpha with black, so the + /// red layer underneath darkens outside the edge. The halo is the same at Intensity 1 and 3 + /// and with Composite Original On Top or Behind; its width follows Glow Radius. + #[test] + fn glow_halo_matches_after_effects() { + let grey = effectcraft_raster::Image::filled(200, 120, [0.5, 0.5, 0.5, 1.0]); + // AE's red under the halo at 0.5, 1.5, 3.5, 5.5 and 7.5 px outside the left edge + // (radius 10) and at 0.5, 5.5, 10.5, 20.5 px (radius 30); halo alpha = 1 − red. + let r10 = [(0.5, 140.0), (1.5, 165.0), (3.5, 208.0), (5.5, 234.0), (7.5, 248.0)]; + let r30 = [(0.5, 132.0), (5.5, 173.0), (10.5, 208.0), (20.5, 245.0)]; + for (radius, intensity, operation, profile) in [(10.0, 1.0, 1, &r10[..]), (10.0, 3.0, 1, &r10[..]), (10.0, 1.0, 0, &r10[..]), (30.0, 1.0, 1, &r30[..])] + { + let set = [ + ("based", Value::Enum(1)), + ("threshold", num(60.0)), + ("radius", num(radius)), + ("intensity", num(intensity)), + ("operation", Value::Enum(operation)), + ]; + let o = run_fx("ec.stylize.glow", &set, grey.clone(), 0.0, EffectEnv::default()); + for &(d, red) in profile { + // Layer x = −d (pixel centre), mid-height; over AE's red: R = 1 − α, colour 0. + let (bx, by) = o.to_px([-d, 60.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + let ae = 1.0 - red / 255.0; + assert!((p[3] - ae).abs() < 3.0 / 255.0, "radius {radius} intensity {intensity} op {operation} at {d} px: alpha {} vs AE {ae}", p[3]); + assert!(p[0].abs() < 1e-6, "the halo is black: {p:?}"); + } + } + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..87f206072 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -324,10 +324,11 @@ fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.f[2] = cb; let bright = e.image(b.img.width, b.img.height); e.pixels("glow_bright", &p, &b.img, None, &bright, None); - let s = (radius / 2.0).max(0.5); + let s = effectcraft_effects::glow_sigma(radius); let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false); let mut p = Params::default(); p.u[0][0] = ctx.params.e("operation").min(2); + p.u[0][1] = (ctx.params.e("based") == 0) as u32; p.f[0][0] = intensity; let out = e.scratch(b.img.width, b.img.height); e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None); diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index f5b7708d9..5f4f33e82 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -943,11 +943,12 @@ pub(crate) fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { p.f[2] = ctx.params.color("colorB"); e.pixels("glow_bright", &p, &b.img, None, &bright, None); } - let s = (radius / 2.0).max(0.5); + let s = effectcraft_effects::glow_sigma(radius); let blurred = gaussian_blur(e, &bright, s * kx, s * ky, false); let operation = ctx.params.e("operation"); let glow_op = effectcraft_effects::glow_operation(ctx); let mut p = Params::default(); + p.u[0][1] = (ctx.params.e("based") == 0) as u32; p.f[0][0] = intensity; let out = e.scratch(b.img.width, b.img.height); if operation == 0 && glow_op != BlendMode::Add { diff --git a/crates/gpu/src/shaders/fx_stylize.wgsl b/crates/gpu/src/shaders/fx_stylize.wgsl index 3db442750..97db06c52 100644 --- a/crates/gpu/src/shaders/fx_stylize.wgsl +++ b/crates/gpu/src/shaders/fx_stylize.wgsl @@ -922,18 +922,28 @@ fn fxs_glow_map(@builtin(global_invocation_id) gid: vec3) { let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0); let t = glow_ab_t(l); let c = vec3(curve_eval(P.f[1].x, t), curve_eval(P.f[1].y, t), curve_eval(P.f[1].z, t)); - textureStore(out, p, vec4(c * k * a, k * a)); + // Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow). + var ga = a; + if (P.u[0].y != 0u) { + ga = k * a; + } + textureStore(out, p, vec4(c * k * a, ga)); } // On Top with another Glow Operation: blend_pixel(mode, original, glow · intensity, 0). -// aux = the blurred glow; u[0].x = blend mode id; f[0].x = intensity +// aux = the blurred glow; u[0].x = blend mode id; u[0].y = based on alpha (intensity also +// scales the glow's alpha); f[0].x = intensity @compute @workgroup_size(16, 16) fn fxs_glow_op(@builtin(global_invocation_id) gid: vec3) { let p = vec2(gid.xy); if (!fxs_inside(p)) { return; } - let g = textureLoad(aux, p, 0) * P.f[0].x; + let gs = textureLoad(aux, p, 0); + var g = vec4(gs.xyz * P.f[0].x, gs.w); + if (P.u[0].y != 0u) { + g.w = gs.w * P.f[0].x; + } var o = blend_pixel(P.u[0].x, textureLoad(src, p, 0), g, 0.0); o.w = clamp(o.w, 0.0, 1.0); textureStore(out, p, o); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..5ee02c69a 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -380,19 +380,25 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3) { l = a; } let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0); + // Based on the colour channels the glow covers the layer's whole alpha (effects::misc::glow). + var ga = a; + if (P.u[0].y != 0u) { + ga = k * a; + } if (P.u[0].x != 0u) { let t = glow_ab_t(l); let ca = P.f[1].xyz; let cb = P.f[2].xyz; let c = ca + (cb - ca) * t; - textureStore(out, p, vec4(c * k * a, k * a)); + textureStore(out, p, vec4(c * k * a, ga)); } else { - textureStore(out, p, px * k); + textureStore(out, p, vec4(px.xyz * k, ga)); } } // Glow composite: original `src` with the blurred glow `aux`. u[0].x = 0 on top, 1 behind, -// 2 none (glow only); f[0].x = intensity. +// 2 none (glow only); u[0].y = based on alpha (intensity also scales the glow's alpha); +// f[0].x = intensity. @compute @workgroup_size(16, 16) fn glow_combine(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -401,7 +407,11 @@ fn glow_combine(@builtin(global_invocation_id) gid: vec3) { return; } var o = textureLoad(src, p, 0); - let g = textureLoad(aux, p, 0) * P.f[0].x; + let gs = textureLoad(aux, p, 0); + var g = vec4(gs.xyz * P.f[0].x, gs.w); + if (P.u[0].y != 0u) { + g.w = gs.w * P.f[0].x; + } if (P.u[0].x == 2u) { o = g; } else if (P.u[0].x == 1u) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index c5fbc5ba8..3fd188d9f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -42,7 +42,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | | Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | | Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | -| Stylize | 25 | 25 | Motion Tile output size (#369) | +| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; the brightness of glows above the threshold is not matched yet; Motion Tile output size (#369) | | Text | 2 | 2 | — | | Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | | Transition | 17 | 17 | — | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From a8d4514a1b3e6b27d9af42e859c1cf2645c12347 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 12:08:26 +0200 Subject: [PATCH 0174/1386] Glow: per-channel bright pass with After Effects' gain, glows above the threshold as bright as After Effects With Glow Colors = Original Colors, After Effects thresholds each channel of the straight colour separately and the glow is 8 x (value - threshold + 0.061) per channel (times Intensity), nothing below: fitted to AE 2026 renders of grey 0.65 / 0.8 / 0.9 / 1, orange (1, 0.5, 0) and (0.2, 0.6, 1) solids at Threshold 40 / 60 / 80 % and Radius 10 / 30 (36 channel values, exact to the 8-bit capture; an orange solid glows red only). EffectCraft scaled the colour by a luminance threshold (k = (L - thr) / (1 - thr)), about 3.7 x too dim on white. Cases 806 / 810 / 811 (32 bpc): outside the layer now within 3/255 of AE (806 white at Intensity 1: 119 = AE, was 32); grid 0 points off for 806-810. The glow's alpha (the blurred layer alpha) and the Arbitrary Map / A & B colours and alpha-based glows are unchanged. Stacked on fix-glow-halo (same lines). Not changed: Composite Original's semantics are swapped against AE's names (EffectCraft's "On Top" adds the glow over the original, which AE calls "Behind"; AE's "On Top" keeps the original over the glow), so imported AE Glows at the default Behind show no glow inside the layer (811 interior 204 vs AE 255). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/misc.rs | 52 ++++++++++++++++++++++++++++- crates/gpu/src/shaders/kernels.wgsl | 6 +++- docs/effects-parity.md | 3 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 58e2b2c55..23e9a6844 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -127,6 +127,20 @@ pub fn glow_sigma(radius: f64) -> f64 { (radius * 0.384).max(0.5) } +/// Glow's bright pass for the original colours, per channel of the straight colour `v`: +/// `8 × (v − threshold + 0.0611)`, nothing below. Fitted to After Effects 2026 renders of grey +/// 0.65 / 0.8 / 0.9 / 1, orange and light-blue solids at Threshold 40 / 60 / 80 % and Radius 10 +/// / 30 (36 channel values, exact to the 8-bit capture): the glow is linear in each channel, +/// its gain is the same at every threshold and radius, and channels below the threshold add +/// nothing (an orange solid glows red only). +pub fn glow_bright_channel(v: f32, thr: f32) -> f32 { + (GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0) +} + +/// [`glow_bright_channel`]'s gain and threshold offset. +pub const GLOW_GAIN: f32 = 8.0; +pub const GLOW_KNEE: f32 = 0.0611; + /// Glow Operation options (blend modes the glow is applied with). pub const GLOW_OPERATIONS: [&str; 17] = [ "Normal", @@ -201,8 +215,12 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, ga]; - } else { + } else if alpha_based { *px = [px[0] * k, px[1] * k, px[2] * k, ga]; + } else { + // Original colours: each channel of the straight colour against the threshold. + let c = [px[0] / a, px[1] / a, px[2] / a].map(|v| glow_bright_channel(v, thr) * a); + *px = [c[0], c[1], c[2], ga]; } }); let s = glow_sigma(radius); @@ -792,6 +810,38 @@ mod tests { } } + /// After Effects 2026, Glow at Threshold 60 %, Radius 10, Intensity 0.25 on 200 × 200 + /// solids over red: the glow 1.5 px outside the left edge, premultiplied (the green / blue + /// channels of the render, where the red background adds nothing), and its alpha 0.353. + #[test] + fn glow_brightness_matches_after_effects() { + // (solid colour, channel, AE glow value 0..255) + let cases = [ + ([0.65, 0.65, 0.65], 1, 20.0), + ([0.8, 0.8, 0.8], 1, 47.0), + ([0.9, 0.9, 0.9], 1, 65.0), + ([1.0, 1.0, 1.0], 1, 83.0), + ([1.0, 0.5, 0.0], 1, 0.0), + ([0.2, 0.6, 1.0], 1, 11.0), + ([0.2, 0.6, 1.0], 2, 83.0), + ]; + for (c, ch, ae) in cases { + let img = effectcraft_raster::Image::filled(200, 200, [c[0], c[1], c[2], 1.0]); + let set = [("threshold", num(60.0)), ("radius", num(10.0)), ("intensity", num(0.25))]; + let o = run_fx("ec.stylize.glow", &set, img, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[ch] * 255.0 - ae).abs() <= 2.5, "{c:?} channel {ch}: {} vs AE {ae}", p[ch] * 255.0); + assert!((p[3] - 0.353).abs() < 3.0 / 255.0, "{c:?}: alpha {}", p[3]); + } + // Intensity scales the colour linearly: white at Intensity 1 is 3.7 × its alpha. + let white = effectcraft_raster::Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]); + let o = run_fx("ec.stylize.glow", &[("threshold", num(60.0)), ("radius", num(10.0))], white, 0.0, EffectEnv::default()); + let (bx, by) = o.to_px([-1.5, 100.0]); + let p = o.img.get(bx.floor() as i64, by.floor() as i64); + assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}"); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 5ee02c69a..ca28e4fc7 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -391,8 +391,12 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3) { let cb = P.f[2].xyz; let c = ca + (cb - ca) * t; textureStore(out, p, vec4(c * k * a, ga)); - } else { + } else if (P.u[0].y != 0u) { textureStore(out, p, vec4(px.xyz * k, ga)); + } else { + // Original colours, per channel (effects::misc::glow_bright_channel). + let c = max(8.0 * (px.xyz / a - vec3(thr - 0.0611)), vec3(0.0)); + textureStore(out, p, vec4(c * a, ga)); } } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 3fd188d9f..67b02d8bd 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -42,7 +42,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | | Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | | Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | -| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; the brightness of glows above the threshold is not matched yet; Motion Tile output size (#369) | +| Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) | | Text | 2 | 2 | — | | Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | | Transition | 17 | 17 | — | @@ -64,6 +64,7 @@ the GPU is performance, not parity. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | From ae7081077fafbe626eac6156222724bc05ec0f29 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:57:20 +0200 Subject: [PATCH 0175/1386] Radial Blur: samples outside the layer repeat its edge, as After Effects After Effects' Radial Blur keeps a layer's edges: Zoom 20 on a flat grey 960x540 solid over red (case 713) stays flat and opaque to its edge, and a zoomed full-frame ramp (case 829) keeps the frame opaque (2/255 at x = 2, 253/255 at x = 1917 on row 540). EffectCraft sampled transparency outside the layer, so its edges faded and the red below showed through (713: 3.9 % of the frame off, max 64/255). Now 713 is identical to AE (max 0/255), CPU and GPU. CC Radial Blur (already matching AE) is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/misc.rs | 27 +++++++++++++++++++++++++ crates/gpu/src/shaders/fx_generate.wgsl | 5 ++++- crates/raster/src/blur.rs | 4 +++- docs/effects-parity.md | 3 ++- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 23e9a6844..848a04f2d 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -842,6 +842,33 @@ mod tests { assert!((p[1] / p[3] - 3.69).abs() < 0.05, "{p:?}"); } + /// After Effects 2026: Radial Blur keeps the layer's edges. Zoom 20 on a flat grey 960 × 540 + /// solid (case 713) leaves it flat and opaque to the edge (EffectCraft faded the edges toward + /// transparency, letting the red layer below show through); on a full-frame horizontal ramp + /// (case 829, centre [960, 540]) row 540 reads 2/255 at x = 2 and 253/255 at x = 1917. + #[test] + fn radial_blur_keeps_the_layer_edges_as_after_effects() { + let zoom = |c: [f64; 2]| [("amount", num(20.0)), ("type", Value::Enum(1)), ("center", Value::Vec2(c))]; + let grey = effectcraft_raster::Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let o = run_fx("ec.blur.radial", &zoom([600.0, 300.0]), grey, 0.0, EffectEnv::default()); + for (x, y) in [(0, 270), (1, 270), (959, 270), (480, 0), (480, 539), (0, 0), (959, 539)] { + let p = o.img.get(x, y); + assert!((p[3] - 1.0).abs() < 1e-4 && (p[0] - 0.5).abs() < 1e-4, "({x}, {y}): {p:?}"); + } + let mut ramp = effectcraft_raster::Image::new(1920, 1080); + ramp.rows_mut().for_each(|(_, row)| { + for (x, px) in row.iter_mut().enumerate() { + let v = (x as f32 + 0.5) / 1920.0; + *px = [v, v, v, 1.0]; + } + }); + let o = run_fx("ec.blur.radial", &zoom([960.0, 540.0]), ramp, 0.0, EffectEnv::default()); + for (x, ae) in [(2, 2.0), (1917, 253.0)] { + let p = o.img.get(x, 540); + assert!((p[0] * 255.0 - ae).abs() <= 2.0 && (p[3] - 1.0).abs() < 1e-4, "x {x}: {p:?} vs AE {ae}"); + } + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0ba..2b571d582 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -67,7 +67,10 @@ fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3) { let cs = data[2u * i + 1u]; s = vec2(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs); } - acc += sample_bilinear(src, s.x, s.y); + // Edge samples repeat (raster::radial_blur). + let hi = vec2(f32(dims.x), f32(dims.y)) - vec2(0.5); + let sc = clamp(s, vec2(0.5), max(hi, vec2(0.5))); + acc += sample_bilinear(src, sc.x, sc.y); } textureStore(out, p, acc / f32(n)); } diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 1c065d77b..df0b84379 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -203,7 +203,9 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) -> let (s, c) = (t * amount).to_radians().sin_cos(); (center.0 + vx * c - vy * s, center.1 + vx * s + vy * c) }; - let p = img.sample_bilinear(sx, sy); + // Samples outside the image repeat its edge: After Effects' Radial Blur keeps a + // layer's edges (cases 713, 829) instead of fading them toward transparency. + let p = img.sample_bilinear(sx.clamp(0.5, (img.width as f64 - 0.5).max(0.5)), sy.clamp(0.5, (img.height as f64 - 0.5).max(0.5))); for ch in 0..4 { acc[ch] += p[ch]; } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 67b02d8bd..ffc478182 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -28,7 +28,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k |---|---:|---:|---| | 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473; quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) | | Audio | 13 | 0 | CPU by design (applied in the mixdown) | -| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | +| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | | Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | @@ -66,6 +66,7 @@ the GPU is performance, not parity. |---|---|---| | 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | +| 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 47b3582a0a1a9c9255909344287e0244c3310759 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:06:47 +0200 Subject: [PATCH 0176/1386] Venetian Blinds: stripes from the layer centre, Gaussian feather on both edges, as After Effects After Effects measures the blinds' phase from the layer's centre and softens both edges of the transparent band with a Gaussian of sigma = Feather / 2; the band runs from the origin's phase to Completion x Width. EffectCraft measured from the buffer corner and ramped only one edge linearly over Feather (a hard edge on the other side). Fitted to AE 2026 renders of Completion 40, Direction 30, Width 50, Feather 5 on a 960 x 540 and a 480 x 270 layer (the origin is the centre + 0.6 px): case 715 was 499 of 5184 grid points off (max 91/255, 7.1 % of pixels > 8), now 28 (0.056 %), all within ~6 px of the layer edge where AE differs (up to 48/255, not modelled); away from it within 6/255 (test: one AE row of the 480 x 270 render). CPU and GPU (new erf-based kernel, same term count). Completion 0 / 100 are unchanged, so the Transitions presets keep their start and end frames. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/misc.rs | 94 +++++++++++++++++++++++-- crates/gpu/src/fx_generate.rs | 26 ++++--- crates/gpu/src/shaders/fx_generate.wgsl | 43 +++++++---- docs/effects-parity.md | 3 +- 5 files changed, 141 insertions(+), 27 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89be21ddb..ff10c99c9 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, venetian_cover, venetian_origin}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 848a04f2d..90306be08 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -556,16 +556,58 @@ fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). +fn norm_cdf(z: f64) -> f64 { + let x = z.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * x); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-x * x).exp(); + if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` +/// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). +/// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 +/// and 480 × 270 layers): band from phase 0 to done × width, both edges Gaussian, within 6/255. +pub fn venetian_cover(d: f64, width: f64, done: f64, sigma: f64) -> f64 { + let band = done * width; + if sigma < 1e-3 { + return if d < band { 1.0 } else { 0.0 }; + } + // Wider than a few blinds the stripes blur into an even veil. + if sigma > 8.0 * width { + return done.clamp(0.0, 1.0); + } + let n = ((4.0 * sigma / width).ceil() as i64 + 1).clamp(1, 40); + let mut t = 0.0; + for k in -n..=n { + let u = d + k as f64 * width; + t += norm_cdf(u / sigma) - norm_cdf((u - band) / sigma); + } + t.clamp(0.0, 1.0) +} + +/// Venetian Blinds' phase origin in layer pixels: the layer centre plus 0.6 px (fitted to the +/// AE renders above; the stripes are measured from the layer's centre, not its corner). +pub fn venetian_origin(layer_size: [f64; 2]) -> [f64; 2] { + [layer_size[0] / 2.0 + 0.6, layer_size[1] / 2.0 + 0.6] +} + fn venetian(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } let ang = ctx.params.f("direction").to_radians(); let width = (ctx.params.f("width") * b.scale).max(1.0); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0; let (dx, dy) = (ang.cos(), ang.sin()); + let (ox, oy) = b.to_px(venetian_origin(ctx.layer_size)); b.img.rows_mut().for_each(|(y, row)| { + let ry = (y as f64 + 0.5 - oy) * dy; for (x, px) in row.iter_mut().enumerate() { - let d = ((x as f64 + 0.5) * dx + (y as f64 + 0.5) * dy).rem_euclid(width); - let k = ((d - done * width) / feather + 0.5).clamp(0.0, 1.0) as f32; + let d = ((x as f64 + 0.5 - ox) * dx + ry).rem_euclid(width); + let k = (1.0 - venetian_cover(d, width, done, sigma)) as f32; for c in px.iter_mut() { *c *= k; } @@ -869,6 +911,50 @@ mod tests { } } + /// After Effects 2026, Venetian Blinds Completion 40, Direction 30, Width 50, Feather 5 on + /// an opaque 480 × 270 layer: alpha (0..255) along row y = 135. The transparent band starts + /// at the layer centre's phase, both edges are Gaussian (σ = Feather / 2). + #[test] + fn venetian_blinds_match_after_effects() { + let img = effectcraft_raster::Image::filled(480, 270, [0.5, 0.5, 0.5, 1.0]); + let vals = [("completion", Value::Scalar(40.0)), ("direction", Value::Scalar(30.0)), ("width", Value::Scalar(50.0)), ("feather", Value::Scalar(5.0))]; + let o = run_fx("ec.transition.venetian", &vals, img, 0.0, EffectEnv::default()); + let ae = [ + (200, 5.0), + (203, 47.0), + (206, 145.0), + (209, 228.0), + (212, 253.0), + (224, 255.0), + (236, 238.0), + (239, 168.0), + (242, 66.0), + (245, 10.0), + (251, 0.0), + (257, 2.0), + (260, 31.0), + ]; + for (x, a) in ae { + let got = o.img.get(x, 135)[3] * 255.0; + assert!((got - a).abs() <= 6.0, "x {x}: alpha {got} vs AE {a}"); + } + // No completion leaves the layer; full completion clears it; no feather is a hard edge. + let full = [("completion", Value::Scalar(100.0)), ("feather", Value::Scalar(5.0))]; + let img = effectcraft_raster::Image::filled(40, 20, [0.5, 0.5, 0.5, 1.0]); + assert!(run_fx("ec.transition.venetian", &full, img.clone(), 0.0, EffectEnv::default()).img.data.iter().all(|p| p[3] < 1e-4)); + assert_eq!(run_fx("ec.transition.venetian", &[("completion", Value::Scalar(0.0))], img.clone(), 0.0, EffectEnv::default()).img, img); + let hard = run_fx( + "ec.transition.venetian", + &[("completion", Value::Scalar(50.0)), ("width", Value::Scalar(10.0)), ("feather", Value::Scalar(0.0))], + img, + 0.0, + EffectEnv::default(), + ); + assert!(hard.img.data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + // A feather far wider than the blinds is an even veil. + assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4fa..7f2678a57 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -204,24 +204,32 @@ fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return Some(b); + } let ang = ctx.params.f("direction").to_radians(); let width = (ctx.params.f("width") * b.scale).max(1.0); - let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let sigma = ctx.params.f("feather").max(0.0) * b.scale / 2.0; + // The CPU's even veil and term count (effects::venetian_cover). + if sigma > 8.0 * width { + return None; + } let (dx, dy) = (ang.cos(), ang.sin()); - let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize); + let (ox, oy) = b.to_px(effectcraft_effects::venetian_origin(ctx.layer_size)); + let mut data = Vec::with_capacity((4 * b.img.width + 2 * b.img.height) as usize); for x in 0..b.img.width { - let cx = ((x as f64 + 0.5) * dx).rem_euclid(width); - data.extend(hl(cx - done * width)); - data.extend(hl(cx - width - done * width)); + let cx = ((x as f64 + 0.5 - ox) * dx).rem_euclid(width); + data.extend(hl(cx)); data.extend(hl(cx - width)); } for y in 0..b.img.height { - data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width))); + data.extend(hl(((y as f64 + 0.5 - oy) * dy).rem_euclid(width))); } let mut p = Params::default(); - p.u[0][0] = 6 * b.img.width; - p.f[0][0] = feather as f32; + p.u[0][0] = 4 * b.img.width; + p.u[0][1] = if sigma < 1e-3 { 0 } else { ((4.0 * sigma / width).ceil() as u32 + 1).clamp(1, 40) }; + p.f[0] = [width as f32, (done * width) as f32, sigma as f32, 0.0]; run(e, "gen_venetian", &p, b, None, Some(&data)) } diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index 2b571d582..aa132217a 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -42,6 +42,15 @@ fn gen_sum(a: vec2, b: vec2) -> f32 { return (a.x + b.x) + (a.y + b.y); } +// Standard normal CDF (Abramowitz & Stegun 7.1.26 erf; effects::misc::norm_cdf). +fn gen_norm_cdf(z: f32) -> f32 { + let x = abs(z) * 0.70710678; + let t = 1.0 / (1.0 + 0.3275911 * x); + let poly = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))); + let erf = 1.0 - poly * exp(-x * x); + return select(0.5 * (1.0 - erf), 0.5 * (1.0 + erf), z >= 0.0); +} + // ---------------------------------------------------------------- Radial Blur (raster::radial_blur) // u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom). @@ -238,9 +247,9 @@ fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, textureLoad(src, p, 0) * k); } -// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as -// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w. -// u[0].x = row table offset; f[0].x = feather. +// Venetian Blinds (effects::venetian_cover). Per column (4 floats): cx, (cx − w) as (hi, lo), +// with cx = ((x + 0.5 − ox)·dx) mod w; per row (2 floats): ((y + 0.5 − oy)·dy) mod w. +// u[0] = (row table offset, Gaussian terms per side, 0 = hard edge); f[0] = (w, band, σ). @compute @workgroup_size(16, 16) fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -248,18 +257,28 @@ fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { if (p.x >= dims.x || p.y >= dims.y) { return; } - let col = 6u * u32(p.x); + let col = 4u * u32(p.x); let row = gen_hl(P.u[0].x + 2u * u32(p.y)); // (d mod w) wraps once the column and row terms reach w. - let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0; - var t: f32; - if (wrapped) { - t = gen_sum(gen_hl(col + 2u), row); - } else { - t = gen_sum(gen_hl(col), row); + var d = gen_sum(gen_hl(col), row); + if (gen_sum(gen_hl(col + 2u), row) >= 0.0) { + d = gen_sum(gen_hl(col + 2u), row); } - let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0); - textureStore(out, p, textureLoad(src, p, 0) * k); + let w = P.f[0].x; + let band = P.f[0].y; + let sigma = P.f[0].z; + let n = i32(P.u[0].y); + var t = 0.0; + if (n == 0) { + t = select(0.0, 1.0, d < band); + } else { + for (var k = -n; k <= n; k++) { + let u = d + f32(k) * w; + t += gen_norm_cdf(u / sigma) - gen_norm_cdf((u - band) / sigma); + } + } + let cover = clamp(t, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * (1.0 - cover)); } // Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, diff --git a/docs/effects-parity.md b/docs/effects-parity.md index ffc478182..f4da80ba7 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -45,7 +45,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) | | Text | 2 | 2 | — | | Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | -| Transition | 17 | 17 | — | +| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255) | | Utility | 9 | 7 | — | Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is @@ -67,6 +67,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | +| 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 8d41ef72183fe4dad5d8c79453c5a55eba322d78 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 11:45:32 +0200 Subject: [PATCH 0177/1386] Radial Wipe: Feather in pixels on both edges of the wedge, as After Effects After Effects feathers both edges of the wiped wedge (the start ray too) with a Gaussian whose width is constant in pixels: Completion 40, Start Angle 30, Counterclockwise, Feather 20 (AE 2026, case 904 E12) fits sigma = 0.4 x Feather = 8.0 px at every radius (mean alpha error 0.0002 over the frame). EffectCraft ramped only the moving edge, linearly over Feather degrees, so the edge was 14 px wide near the centre and 140 px wide at 400 px out, and the start edge was hard: 378 of 5184 grid points off (max 111/255, 6.6 % of pixels > 8), now 1 (max 3), 0.009 % of pixels > 8 (near the centre). CPU and GPU (the kernel now measures distances to the two rays). 0 % and 100 % are unchanged, so the three Transitions presets keep their end frames; their Feather (6 / 10) now means pixels. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/misc.rs | 132 +++++++++++++++++++----- crates/gpu/src/fx_generate.rs | 38 +++---- crates/gpu/src/shaders/fx_generate.wgsl | 56 +++++----- docs/effects-parity.md | 3 +- 5 files changed, 148 insertions(+), 83 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ff10c99c9..d942ff13d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, venetian_cover, venetian_origin}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, venetian_origin}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index 90306be08..fa92c998e 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -532,30 +532,6 @@ fn linear_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } -fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { - let done = ctx.params.f("completion") / 100.0; - let start = ctx.params.f("startAngle"); - let c = b.to_px(ctx.params.v2("center")); - let dir = ctx.params.e("wipe"); - let feather = (ctx.params.f("feather")).max(0.01); - b.img.rows_mut().for_each(|(y, row)| { - for (x, px) in row.iter_mut().enumerate() { - let a = ((x as f64 + 0.5 - c.0).atan2(-(y as f64 + 0.5 - c.1)).to_degrees() - start).rem_euclid(360.0); - // Both: the wipe opens in both directions from the start angle at once. - let (a, edge) = match dir { - 1 => (360.0 - a, done * 360.0), - 2 => (a.min(360.0 - a), done * 180.0), - _ => (a, done * 360.0), - }; - let k = ((a - edge) / feather + 0.5).clamp(0.0, 1.0) as f32; - for ch in px.iter_mut() { - *ch *= k; - } - } - }); - b -} - /// Standard normal CDF Φ (Abramowitz & Stegun 7.1.26 erf, |error| < 1.5e-7). fn norm_cdf(z: f64) -> f64 { let x = z.abs() / std::f64::consts::SQRT_2; @@ -565,6 +541,66 @@ fn norm_cdf(z: f64) -> f64 { if z >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } } +/// Radial Wipe's wiped wedge relative to the Start Angle: (first ray, span), degrees clockwise +/// from up. Clockwise (0) wipes from the start angle on, Counterclockwise (1) back from it, +/// Both (2) half each way. +pub fn radial_wipe_wedge(wipe: u32, done: f64) -> (f64, f64) { + let span = done.clamp(0.0, 1.0) * 360.0; + match wipe { + 1 => (-span, span), + 2 => (-span / 2.0, span), + _ => (0.0, span), + } +} + +/// Radial Wipe's feather: a Gaussian of σ = 0.4 × Feather pixels across both edges of the wedge +/// (After Effects 2026, Feather 20: the edge profile is the same width in pixels at every +/// radius, σ = 8.0 px, mean error 0.0002 over the frame). +pub fn radial_wipe_sigma(feather: f64) -> f64 { + feather.max(0.0) * 0.4 +} + +/// Visible fraction of a pixel at `(ux, uy)` from the centre (y down) for a wiped wedge from +/// `ray0` (absolute degrees clockwise from up) over `span` degrees: the signed distance to the +/// wedge's nearer edge, through the feather's Gaussian. +pub fn radial_wipe_alpha(ux: f64, uy: f64, ray0: f64, span: f64, sigma: f64) -> f64 { + let a = (ux.atan2(-uy).to_degrees() - ray0).rem_euclid(360.0); + let wiped = a < span; + let ray_dist = |deg: f64| { + let (s, c) = deg.to_radians().sin_cos(); + // Ray direction (sin, −cos) in y-down pixels. + let along = ux * s - uy * c; + if along > 0.0 { (ux * c + uy * s).abs() } else { ux.hypot(uy) } + }; + let d = ray_dist(ray0).min(ray_dist(ray0 + span)); + let sd = if wiped { -d } else { d }; + if sigma < 1e-3 { + return if wiped { 0.0 } else { 1.0 }; + } + norm_cdf(sd / sigma) +} + +fn radial_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return b; + } + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let (w0, span) = radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let full = done >= 1.0; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let k = if full { 0.0 } else { radial_wipe_alpha(x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1, start + w0, span, sigma) as f32 }; + for ch in px.iter_mut() { + *ch *= k; + } + } + }); + b +} + /// Venetian Blinds: how much of a pixel at phase `d` (0..`width`) a band of `done × width` /// covers, its two edges softened by a Gaussian of σ = Feather / 2 (σ in the units of `width`). /// Fitted to After Effects 2026 (Completion 40, Direction 30, Width 50, Feather 5 on 960 × 540 @@ -955,6 +991,54 @@ mod tests { assert_eq!(venetian_cover(3.0, 10.0, 0.5, 1e9), 0.5); } + /// After Effects 2026, Radial Wipe Completion 40, Start Angle 30, Wipe Counterclockwise, + /// Feather 20, centre (800, 500): visible fraction at pixel offsets from the centre on both + /// edges (8-bit captures of dark steps, so ±0.05). + #[test] + fn radial_wipe_feather_matches_after_effects() { + let img = effectcraft_raster::Image::filled(400, 300, [1.0, 1.0, 1.0, 1.0]); + let vals = [ + ("completion", Value::Scalar(40.0)), + ("startAngle", Value::Scalar(30.0)), + ("center", Value::Vec2([200.0, 150.0])), + ("wipe", Value::Enum(1)), + ("feather", Value::Scalar(20.0)), + ]; + let o = run_fx("ec.transition.radialwipe", &vals, img.clone(), 0.0, EffectEnv::default()); + let ae = [ + ((-50, 34), 0.922), + ((-52, 30), 0.797), + ((-54, 26), 0.609), + ((-55, 24), 0.5), + ((-56, 22), 0.406), + ((-57, 19), 0.266), + ((-58, 15), 0.125), + ((-104, 60), 0.951), + ((-108, 53), 0.732), + ((-111, 45), 0.341), + ((-114, 37), 0.062), + ((22, -56), 0.156), + ((26, -54), 0.312), + ((30, -52), 0.531), + ((34, -50), 0.729), + ((37, -47), 0.875), + ((53, -108), 0.177), + ((67, -99), 0.875), + ]; + for ((dx, dy), a) in ae { + let got = o.img.get(200 + dx, 150 + dy)[3]; + assert!((got - a).abs() < 0.05, "offset ({dx}, {dy}): {got} vs AE {a}"); + } + // 0 % leaves the layer, 100 % clears it, Feather 0 is a hard edge. + let at = |c: f64, f: f64| { + let v = [("completion", Value::Scalar(c)), ("feather", Value::Scalar(f))]; + run_fx("ec.transition.radialwipe", &v, img.clone(), 0.0, EffectEnv::default()).img + }; + assert_eq!(at(0.0, 20.0), img); + assert!(at(100.0, 20.0).data.iter().all(|p| p[3] == 0.0)); + assert!(at(50.0, 0.0).data.iter().all(|p| p[3] == 0.0 || p[3] == 1.0)); + } + #[test] fn glow_operation_and_arbitrary_map() { // Pixels addressed in layer space (Glow pads its buffer). diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 7f2678a57..b32151e33 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -234,33 +234,23 @@ fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let done = ctx.params.f("completion") / 100.0; + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if done <= 0.0 { + return Some(b); + } let start = ctx.params.f("startAngle"); let c = b.to_px(ctx.params.v2("center")); - let dir = ctx.params.e("wipe"); - let feather = ctx.params.f("feather").max(0.01); - let edge = if dir == 2 { done * 180.0 } else { done * 360.0 }; - // Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies). - let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos()); - let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize); - for x in 0..b.img.width { - let ux = x as f64 + 0.5 - c.0; - for (s, co) in rots { - data.extend(hl(ux * co)); - data.push((ux * s) as f32); - } - } - for y in 0..b.img.height { - let uy = -(y as f64 + 0.5 - c.1); - for (s, co) in rots { - data.extend(hl(uy * s)); - data.push((uy * co) as f32); - } - } + let (w0, span) = effectcraft_effects::radial_wipe_wedge(ctx.params.e("wipe"), done); + let sigma = effectcraft_effects::radial_wipe_sigma(ctx.params.f("feather")) * b.scale; + let ray0 = start + w0; + let (s0, c0) = ray0.to_radians().sin_cos(); + let (s1, c1) = (ray0 + span).to_radians().sin_cos(); let mut p = Params::default(); - p.u[0] = [9 * b.img.width, dir.min(2), 0, 0]; - p.f[0] = [edge as f32, feather as f32, 0.0, 0.0]; - run(e, "gen_radial_wipe", &p, b, None, Some(&data)) + p.u[0] = [(done >= 1.0) as u32, (sigma < 1e-3) as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, sigma.max(1e-3) as f32, ray0.rem_euclid(360.0) as f32]; + p.f[1] = [s0 as f32, c0 as f32, s1 as f32, c1 as f32]; + p.f[2][0] = span as f32; + run(e, "gen_radial_wipe", &p, b, None, None) } fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index aa132217a..c4dbb878e 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -281,20 +281,9 @@ fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { textureStore(out, p, textureLoad(src, p, 0) * (1.0 - cover)); } -// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, -// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe. -fn gen_psi(r: u32, x: u32, y: u32) -> f32 { - let c = 9u * x + 3u * r; - let w = P.u[0].x + 9u * y + 3u * r; - // x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R. - let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]); - let yr = data[w + 2u] + data[c + 2u]; - return atan2(xr, yr) * GEN_DEG; -} - -// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R), -// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe); -// f[0] = (edge, feather). +// Radial Wipe (effects::misc::radial_wipe_alpha). u[0] = (fully wiped, hard edge); +// f[0] = (centre x, centre y, σ, first ray in degrees); f[1] = (sin, cos) of both rays; +// f[2].x = span in degrees. @compute @workgroup_size(16, 16) fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -302,30 +291,31 @@ fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { if (p.x >= dims.x || p.y >= dims.y) { return; } - let x = u32(p.x); - let y = u32(p.y); - let dir = P.u[0].y; - let edge = P.f[0].x; - var a = gen_psi(0u, x, y); - if (a < 0.0) { - a += 360.0; - } - var base: f32; - if (dir == 0u || (dir == 2u && a <= 180.0)) { - base = a - edge; - if (abs(base) < 90.0) { - base = gen_psi(1u, x, y); - } - } else { - base = (360.0 - a) - edge; - if (abs(base) < 90.0) { - base = -gen_psi(2u, x, y); + var k = 0.0; + if (P.u[0].x == 0u) { + let ux = f32(p.x) + 0.5 - P.f[0].x; + let uy = f32(p.y) + 0.5 - P.f[0].y; + var a = atan2(ux, -uy) * GEN_DEG - P.f[0].w; + a = a - 360.0 * floor(a / 360.0); + let wiped = a < P.f[2].x; + let d = min(gen_ray_dist(ux, uy, P.f[1].x, P.f[1].y), gen_ray_dist(ux, uy, P.f[1].z, P.f[1].w)); + if (P.u[0].y != 0u) { + k = select(1.0, 0.0, wiped); + } else { + k = gen_norm_cdf(select(d, -d, wiped) / P.f[0].z); } } - let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); textureStore(out, p, textureLoad(src, p, 0) * k); } +// Distance from (ux, uy) to the ray from the centre in direction (s, −c) (y down). +fn gen_ray_dist(ux: f32, uy: f32, s: f32, c: f32) -> f32 { + if (ux * s - uy * c > 0.0) { + return abs(ux * c + uy * s); + } + return length(vec2(ux, uy)); +} + // Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness). @compute @workgroup_size(16, 16) fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3) { diff --git a/docs/effects-parity.md b/docs/effects-parity.md index f4da80ba7..bd89044bb 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -45,7 +45,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) | | Text | 2 | 2 | — | | Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | -| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255) | +| Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255); Radial Wipe's Feather matches After Effects 2026 (a Gaussian of σ = 0.4 × Feather pixels on both edges of the wedge) | | Utility | 9 | 7 | — | Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is @@ -68,6 +68,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From 60cf195236e067e9d5ec84f2581d45029ca54a9e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 15:25:10 +0200 Subject: [PATCH 0178/1386] Fractal / Turbulent Noise: lattice cells are two thirds of Scale, as After Effects After Effects 2026 draws Fractal Noise's Block noise at Scale 100 and Complexity 1 in cells of 66.67 px (the value changes every 66.67 px along a row), not 100 px; Soft Linear noise has the matching correlation length. EffectCraft's lattice is now Scale x 2/3 px (NOISE_CELL), CPU and GPU. Case 904 E03 (Soft Linear, Complexity 4, Scale 100): the noise's autocorrelation at 32 px was 0.68 (AE 0.41), now 0.54; pixel values cannot match (other seeds). Still open: AE's noise spreads about 20 % wider around its mean (std 48 vs 40/255 in E03). Existing projects and the Backgrounds presets with Fractal Noise get finer noise (two thirds of the former cell size). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/noise3.rs | 31 ++++++++++++++++++++++++++++--- crates/gpu/src/effects.rs | 14 ++++++++++++-- docs/effects-parity.md | 3 ++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d942ff13d..700a84e0e 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -103,7 +103,7 @@ pub use transition::place_layer; // effectcraft-gpu fx_noise. pub use keying2::{InnerOuterPlan, inner_outer_plan}; pub use noise::remove_grain_level; -pub use noise3::{FractalGpu, fractal_gpu}; +pub use noise3::{FractalGpu, NOISE_CELL, fractal_gpu}; pub use stylize3::{BumpLight, bump_layer_id}; pub use textfx::{TextLayer, text_layer}; pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan}; diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..f94680495 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -60,6 +60,11 @@ pub const BLEND_NORMAL: u32 = 1; /// Index of "Soft Linear" in [`NOISE_TYPES`] (the default, and the GPU path's interpolation). pub const NOISE_SOFT_LINEAR: u32 = 2; +/// Lattice cell of Fractal / Turbulent Noise in pixels per percent of Scale: After Effects 2026 +/// draws Block noise at Scale 100 in 66.67 px cells (and Soft Linear noise has the matching +/// correlation length), not 100 px. +pub const NOISE_CELL: f64 = 2.0 / 3.0; + fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } } @@ -236,7 +241,7 @@ impl Fractal { overflow: pr.e("overflow"), invert: pr.b("invert"), rot: rot.sin_cos(), - size: [(sw * b.scale).max(1.0) as f32, (sh * b.scale).max(1.0) as f32], + size: [(sw * NOISE_CELL * b.scale).max(1.0) as f32, (sh * NOISE_CELL * b.scale).max(1.0) as f32], origin: [ox as f32, oy as f32], center: [cx as f32, cy as f32], perspective: pr.b("transform/perspectiveOffset"), @@ -519,14 +524,34 @@ mod tests { #[test] fn noise_types_differ_and_block_is_piecewise_constant() { - let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); - // Block noise: neighbours inside one 10 px cell share a value. + let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(15.0))]); + // Block noise: neighbours inside one 10 px cell (Scale 15) share a value. assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); } + /// After Effects 2026 (R9): Block noise at Scale 100, Complexity 1 changes value every + /// 66.67 px along a row. + #[test] + fn block_noise_cells_are_two_thirds_of_scale_as_in_after_effects() { + let img = run_fx( + "ec.noise.fractal", + &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("blendingMode", Value::Enum(BLEND_NONE))], + Image::filled(400, 8, [0.0, 0.0, 0.0, 1.0]), + 0.0, + EffectEnv::default(), + ) + .img; + let changes: Vec = (1..400).filter(|&x| img.get(x, 4) != img.get(x - 1, 4)).collect(); + assert!(changes.len() >= 4, "{changes:?}"); + for w in changes.windows(2) { + let d = (w[1] - w[0]) as f64; + assert!((d - 200.0 / 3.0).abs() <= 1.0, "cell width {d}: {changes:?}"); + } + } + #[test] fn scale_width_height_apply_without_uniform_scaling() { let base = &[("complexity", num(1.0)), ("transform/scale", num(8.0))]; diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 87f206072..49ab724bf 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -529,9 +529,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { let sub = (f("subSettings/subScaling") / 100.0).clamp(0.1, 1.0); p.u[0] = [10, kind, octaves.ceil() as u32, f("evolutionOptions/seed") as u32 ^ 0x51ed]; p.u[1] = [pr.b("invert") as u32, pr.e("overflow"), mode, 0]; - p.f[0] = [f("contrast") as f32 / 100.0, f("brightness") as f32 / 100.0, (sw * b.scale).max(1.0) as f32, (octaves - octaves.floor()) as f32]; + p.f[0] = [ + f("contrast") as f32 / 100.0, + f("brightness") as f32 / 100.0, + (sw * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32, + (octaves - octaves.floor()) as f32, + ]; p.f[1] = [ox as f32, oy as f32, sr, cr]; - p.f[2] = [f("evolution") as f32 / 360.0, f("blend") as f32 / 100.0, (f("opacity") as f32 / 100.0).clamp(0.0, 1.0), (sh * b.scale).max(1.0) as f32]; + p.f[2] = [ + f("evolution") as f32 / 360.0, + f("blend") as f32 / 100.0, + (f("opacity") as f32 / 100.0).clamp(0.0, 1.0), + (sh * effectcraft_effects::NOISE_CELL * b.scale).max(1.0) as f32, + ]; p.f[3] = [(f("subSettings/subInfluence") as f32 / 100.0).clamp(0.0, 1.0), 1.0 / sub as f32, 0.0, 0.0]; } _ => return None, diff --git a/docs/effects-parity.md b/docs/effects-parity.md index bd89044bb..950c98549 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -37,7 +37,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Immersive Video | 12 | 12 | — | | Keying | 12 | 12 | Key Cleaner approximated; #533 (wire-removal Frame Offset) is closed | | Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | -| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | +| Noise & Grain | 13 | 12 | Fractal / Turbulent Noise lattice matches After Effects 2026 (Scale 100 = 66.7 px cells); the noise values themselves differ (seeds) and Soft Linear contrast is about 20 % lower than AE's; Curl Noise (#463); Match Grain on the CPU | | Obsolete | 9 | 9 | — | | Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | | Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | @@ -69,6 +69,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From d37c087975cf806ba7d9f91f08f60d678b19d48d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 01:54:22 +0200 Subject: [PATCH 0179/1386] Transform effect: skew after the rotation and drawn outside the layer, as After Effects After Effects draws the Transform effect as position + K * R * S * (p - anchor) with the skew K = rot(-axis) * shearY(tan skew) * rot(axis) applied after the rotation, and keeps what lands outside the layer's bounds. EffectCraft skewed before rotating (shearX about the axis) and clipped to the layer. Measured in AE 2026 on a grey 960x540 solid (cases 723, 819, 820): 723 (anchor, position, scale 120/80, skew 10 axis 20, rotation 30, opacity 70) differed in 11.25 % of the frame and now in 0.065 % (edges, max 16/255), CPU and GPU alike; 819 / 820 (skew 15 axis 60, rotation -25, scale 130/70, anchor) match to the edge. The buffer grows on each side by that side's overshoot (also for the motion-blur samples), capped at 16 384 px a side and 16 Mpx in all so a layer moved far away costs a bounded allocation (regression test); adjustment layers keep their size. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort.rs | 251 +++++++++++++++++++++++++++++----- crates/effects/src/lib.rs | 23 +++- crates/gpu/src/effects.rs | 35 +++-- crates/gpu/src/fx_stylize.rs | 27 ++-- docs/effects-parity.md | 3 +- 5 files changed, 280 insertions(+), 59 deletions(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 534da8061..916e162f6 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -275,27 +275,70 @@ pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> { (angle > 0.0).then_some((angle, phase, n.clamp(2, 64))) } -fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { - let anchor = b.to_px(pr.v2("anchor")); - let pos = b.to_px(pr.v2("position")); +/// The Transform effect's matrix in buffer pixels (`to_px` maps layer pixels into the buffer), +/// as After Effects 2026 draws it (cases 723, 819, 820): `position + K · R · S · (p − anchor)` +/// with the skew `K = rot(−axis) · shearY(tan skew) · rot(axis)` applied after the rotation. +pub fn transform_matrix_px(pr: &crate::Params, to_px: impl Fn([f64; 2]) -> (f64, f64)) -> Mat3 { + let anchor = to_px(pr.v2("anchor")); + let pos = to_px(pr.v2("position")); let sh = pr.f("scaleHeight"); let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + let axis = pr.f("skewAxis"); + let k = pr.f("skew").clamp(-85.0, 85.0).to_radians().tan(); + let k = if k.is_finite() { k } else { 0.0 }; + let skew = Mat3::rotate_deg(-axis) * Mat3([[1.0, 0.0, 0.0], [k, 1.0, 0.0], [0.0, 0.0, 1.0]]) * Mat3::rotate_deg(axis); Mat3::translate(vec2(pos.0, pos.1)) + * skew * Mat3::rotate_deg(pr.f("rotation")) - * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) * Mat3::translate(vec2(-anchor.0, -anchor.1)) } +fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { + transform_matrix_px(pr, |p| b.to_px(p)) +} + +/// The transparent margins (left, top, right, bottom) a `w` x `h` buffer needs so its warp by +/// any of `ms` stays on it: After Effects draws Transform's result outside the layer's bounds +/// (case 723). Each side grows by its own overshoot; the grown buffer is capped at 16 384 px a +/// side and 16 Mpx in all (the margins shrink in proportion), so a layer moved far away costs +/// a bounded allocation. +pub fn transform_grow(ms: &[Mat3], w: u32, h: u32) -> [u32; 4] { + const SIDE: f64 = 16_384.0; + const AREA: f64 = 16_777_216.0; + let (wf, hf) = (w as f64, h as f64); + let mut m = [0.0f64; 4]; + for mat in ms { + for (x, y) in [(0.0, 0.0), (wf, 0.0), (0.0, hf), (wf, hf)] { + let q = mat.apply(vec2(x, y)); + if q.x.is_finite() && q.y.is_finite() { + m = [m[0].max(-q.x), m[1].max(-q.y), m[2].max(q.x - wf), m[3].max(q.y - hf)]; + } + } + } + let m = m.map(|v| v.ceil().max(0.0)); + let fit = |len: f64, a: f64, b: f64| -> f64 { + let room = (SIDE - len).max(0.0); + if a + b > room && a + b > 0.0 { room / (a + b) } else { 1.0 } + }; + let kx = fit(wf, m[0], m[2]); + let ky = fit(hf, m[1], m[3]); + let area = |k: f64| (wf + (m[0] + m[2]) * kx * k) * (hf + (m[1] + m[3]) * ky * k); + let k = if area(1.0) > AREA && area(1.0) > wf * hf { + // Shrink both margins by the same factor until the area fits. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..40 { + let mid = (lo + hi) / 2.0; + if area(mid) > AREA { hi = mid } else { lo = mid } + } + lo + } else { + 1.0 + }; + [m[0] * kx * k, m[1] * ky * k, m[2] * kx * k, m[3] * ky * k].map(|v| v.floor().clamp(0.0, SIDE) as u32) +} + fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { - let anchor = b.to_px(ctx.params.v2("anchor")); - let pos = b.to_px(ctx.params.v2("position")); - let uniform = ctx.params.b("uniform"); - let sh = ctx.params.f("scaleHeight"); - let sw = if uniform { sh } else { ctx.params.f("scaleWidth") }; - let skew = ctx.params.f("skew"); - let skew_axis = ctx.params.f("skewAxis"); - let rot = ctx.params.f("rotation"); let opacity = ctx.params.f("opacity") as f32 / 100.0; let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear }; // Motion blur: the transform at Samples instants across the shutter, averaged (the @@ -304,33 +347,35 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { && let Some(host) = ctx.env.host { let fd = 1.0 / ctx.fps(); - let mut acc = Image::new(b.img.width, b.img.height); - let mut count = 0.0f32; - for k in 0..n { - let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; - let Some(pr) = host.params_at(t) else { continue }; - let mut one = Image::new(b.img.width, b.img.height); - let op = pr.f("opacity") as f32 / 100.0; - effectcraft_raster::composite_warp( - &mut one, - &b.img, - &transform_matrix(&pr, &b), - &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, - ); - acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); - count += 1.0; - } - if count > 0.0 { + let samples: Vec = (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect(); + if !samples.is_empty() { + if !ctx.adjustment { + let ms: Vec = samples.iter().map(|pr| transform_matrix(pr, &b)).collect(); + b.pad_sides(transform_grow(&ms, b.img.width, b.img.height)); + } + let mut acc = Image::new(b.img.width, b.img.height); + for pr in &samples { + let mut one = Image::new(b.img.width, b.img.height); + let op = pr.f("opacity") as f32 / 100.0; + effectcraft_raster::composite_warp( + &mut one, + &b.img, + &transform_matrix(pr, &b), + &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, + ); + acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + } + let count = samples.len() as f32; acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count)); b.img = acc; return b; } } - let m = Mat3::translate(vec2(pos.0, pos.1)) - * Mat3::rotate_deg(rot) - * Mat3::skew_deg(skew, skew_axis) - * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) - * Mat3::translate(vec2(-anchor.0, -anchor.1)); + if !ctx.adjustment { + let m = transform_matrix(ctx.params, &b); + b.pad_sides(transform_grow(&[m], b.img.width, b.img.height)); + } + let m = transform_matrix(ctx.params, &b); let mut out = Image::new(b.img.width, b.img.height); effectcraft_raster::composite_warp(&mut out, &b.img, &m, &effectcraft_raster::WarpOpts { opacity, sampling, ..Default::default() }); b.img = out; @@ -576,6 +621,144 @@ mod tests { } } + /// After Effects 2026 (cases 723, 819, 820): a grey 960 × 540 solid centred in a 1920 × 1080 + /// comp with Transform. The skew applies after the rotation, and the result is drawn outside + /// the layer's bounds. Points are comp pixels sampled from AE's renders (at least 3 px from + /// an edge; true = the solid covers it), half where the old order and clipping differed. + #[test] + fn transform_matches_after_effects() { + let solid = Image::filled(960, 540, [0.5, 0.5, 0.5, 1.0]); + let p2 = |x: f64, y: f64| Value::Vec2([x, y]); + let cases: [(&str, Vec<(&str, Value)>, &[(u32, u32, bool)]); 3] = [ + ( + "723", + vec![ + ("anchor", p2(400.0, 200.0)), + ("position", p2(500.0, 300.0)), + ("uniform", Value::Bool(false)), + ("scaleHeight", num(80.0)), + ("scaleWidth", num(120.0)), + ("skew", num(10.0)), + ("skewAxis", num(20.0)), + ("rotation", num(30.0)), + ], + &[ + (1465, 954, true), + (1127, 821, true), + (1378, 846, true), + (1416, 878, true), + (1486, 850, true), + (492, 467, true), + (706, 239, true), + (1402, 1021, true), + (518, 440, true), + (1218, 882, true), + (454, 448, true), + (1473, 920, true), + (1048, 654, true), + (1050, 486, true), + (1060, 736, true), + (932, 804, true), + (1074, 562, true), + (653, 591, true), + (1353, 466, false), + (88, 1021, false), + (1855, 886, false), + (1796, 401, false), + (497, 913, false), + (1445, 515, false), + ], + ), + ( + "819", + vec![("skew", num(15.0)), ("skewAxis", num(60.0))], + &[ + (465, 367, true), + (554, 793, false), + (481, 529, true), + (1451, 599, true), + (428, 575, true), + (432, 600, true), + (463, 444, true), + (759, 803, false), + (445, 684, true), + (1411, 757, true), + (915, 784, false), + (480, 737, true), + (758, 620, true), + (975, 705, true), + (1289, 376, true), + (575, 420, true), + (1015, 507, true), + (1377, 448, true), + (1679, 171, false), + (194, 435, false), + (413, 159, false), + (327, 741, false), + (1824, 138, false), + (1093, 29, false), + ], + ), + ( + "820", + vec![ + ("skew", num(15.0)), + ("skewAxis", num(60.0)), + ("rotation", num(-25.0)), + ("uniform", Value::Bool(false)), + ("scaleWidth", num(130.0)), + ("scaleHeight", num(70.0)), + ("anchor", p2(100.0, 50.0)), + ], + &[ + (1674, 566, true), + (1425, 317, false), + (1788, 278, true), + (1530, 390, true), + (1531, 652, true), + (1589, 545, true), + (1890, 441, true), + (1724, 400, true), + (1657, 511, true), + (1895, 261, true), + (1697, 344, true), + (933, 840, true), + (1413, 467, true), + (1295, 691, true), + (1353, 548, true), + (1293, 548, true), + (1103, 551, true), + (973, 758, true), + (446, 484, false), + (1009, 420, false), + (144, 761, false), + (1812, 111, false), + (1241, 10, false), + (900, 854, false), + ], + ), + ]; + for (case, set, pts) in cases { + let o = run("ec.distort.transform", &set, solid.clone()); + for &(x, y, inside) in pts { + // Comp pixel → layer pixel (the solid's top-left sits at 480, 270) → buffer. + let (bx, by) = o.to_px([x as f64 + 0.5 - 480.0, y as f64 + 0.5 - 270.0]); + let a = if bx >= 0.0 && by >= 0.0 && bx < o.img.width as f64 && by < o.img.height as f64 { o.img.get(bx as i64, by as i64)[3] } else { 0.0 }; + assert_eq!(a > 0.5, inside, "case {case}: comp pixel ({x}, {y}) alpha {a}"); + } + } + // A huge scale stays within the buffer cap. + let o = run("ec.distort.transform", &[("scaleHeight", num(100_000.0))], Image::filled(8, 8, [1.0; 4])); + assert!(o.img.width <= 16_384 && o.img.height <= 16_384); + // A layer moved far right grows the buffer on that side only, within 16 Mpx. + let o = run("ec.distort.transform", &[("position", p2(960.0 + 2000.0, 540.0))], Image::filled(1920, 1080, [1.0; 4])); + assert_eq!((o.offset[0], o.img.height), (0.0, 1080), "only the right side grows"); + assert!(o.img.width >= 1920 + 2000 && o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + assert!(o.img.get(2000 + 960, 540)[3] > 0.99 && o.img.get(10, 540)[3] < 0.01); + let o = run("ec.distort.transform", &[("position", p2(40_000.0, 30_000.0))], Image::filled(1920, 1080, [1.0; 4])); + assert!(o.img.width as u64 * o.img.height as u64 <= 16_777_216, "{} x {}", o.img.width, o.img.height); + } + #[test] fn transform_sampling_bicubic_differs_from_bilinear() { let im = img(32, 32); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 700a84e0e..71adf2bae 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -77,7 +77,7 @@ pub use color_fx::{ }; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; -pub use distort::transform_shutter; +pub use distort::{transform_grow, transform_matrix_px, transform_shutter}; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; @@ -286,6 +286,27 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by transparent pixels: `sides` = left, top, right, bottom. Content keeps its place. + pub fn pad_sides(&mut self, sides: [u32; 4]) { + let [l, t, r, b] = sides; + if l == 0 && t == 0 && r == 0 && b == 0 { + return; + } + let (Some(w), Some(h)) = (self.img.width.checked_add(l).and_then(|v| v.checked_add(r)), self.img.height.checked_add(t).and_then(|v| v.checked_add(b))) + else { + return; + }; + let mut out = Image::new(w, h); + let (sw, ow) = (self.img.width as usize, w as usize); + for y in 0..self.img.height as usize { + let at = (y + t as usize) * ow + l as usize; + let (Some(src), Some(dst)) = (self.img.data.get(y * sw..(y + 1) * sw), out.data.get_mut(at..at + sw)) else { continue }; + dst.copy_from_slice(src); + } + self.img = out; + self.offset[0] += l as f64; + self.offset[1] += t as f64; + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 49ab724bf..b7c87603c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -2,7 +2,6 @@ //! radii, parameter conversions), with the pixel loops as compute kernels. use effectcraft_effects::{Buf, EffectCtx}; -use effectcraft_geom::{Mat3, vec2}; use effectcraft_raster::Sampling; use effectcraft_render::FxStep; @@ -27,6 +26,24 @@ impl GBuf { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } + /// Buf::pad_sides: grow by transparent pixels (left, top, right, bottom). + pub fn pad_sides(&mut self, e: &mut Enc, sides: [u32; 4]) -> Option<()> { + let [l, t, r, b] = sides; + if l == 0 && t == 0 && r == 0 && b == 0 { + return Some(()); + } + let (w, h) = (self.img.width.checked_add(l)?.checked_add(r)?, self.img.height.checked_add(t)?.checked_add(b)?); + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, l, t); + self.img = out; + self.offset[0] += l as f64; + self.offset[1] += t as f64; + Some(()) + } + /// Buf::pad: grow by `pad` transparent pixels on every side. pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { if pad == 0 { @@ -370,16 +387,14 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() { return None; } - let anchor = b.to_px(ctx.params.v2("anchor")); - let pos = b.to_px(ctx.params.v2("position")); - let sh = ctx.params.f("scaleHeight"); - let sw = if ctx.params.b("uniform") { sh } else { ctx.params.f("scaleWidth") }; let opacity = ctx.params.f("opacity") as f32 / 100.0; - let m = Mat3::translate(vec2(pos.0, pos.1)) - * Mat3::rotate_deg(ctx.params.f("rotation")) - * Mat3::skew_deg(ctx.params.f("skew"), ctx.params.f("skewAxis")) - * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) - * Mat3::translate(vec2(-anchor.0, -anchor.1)); + // As distort::transform: After Effects' matrix, drawn outside the layer's bounds. + if !ctx.adjustment { + let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p)); + let sides = effectcraft_effects::transform_grow(&[m], b.img.width, b.img.height); + b.pad_sides(e, sides)?; + } + let m = effectcraft_effects::transform_matrix_px(ctx.params, |p| b.to_px(p)); let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear }; let empty = e.image(b.img.width, b.img.height); b.img = ops::warp(e, &empty, &b.img, &m, sampling, effectcraft_color::BlendMode::Normal, opacity, 0, None); diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index 5f4f33e82..422a13cc7 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -970,15 +970,7 @@ pub(crate) fn transform_blur(ctx: &EffectCtx) -> bool { /// distort::transform_matrix. fn transform_matrix(pr: &effectcraft_effects::Params, b: &GBuf) -> Mat3 { - let anchor = b.to_px(pr.v2("anchor")); - let pos = b.to_px(pr.v2("position")); - let sh = pr.f("scaleHeight"); - let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; - Mat3::translate(vec2(pos.0, pos.1)) - * Mat3::rotate_deg(pr.f("rotation")) - * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) - * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) - * Mat3::translate(vec2(-anchor.0, -anchor.1)) + effectcraft_effects::transform_matrix_px(pr, |p| b.to_px(p)) } /// distort::transform's motion blur: the transform at Samples instants across the shutter, @@ -988,15 +980,24 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option = + (0..n).filter_map(|k| host.params_at(ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd)).collect(); + if samples.is_empty() { + // The CPU renders the frame sharp. + return None; + } + if !ctx.adjustment { + let ms: Vec = samples.iter().map(|pr| transform_matrix(pr, &b)).collect(); + let sides = effectcraft_effects::transform_grow(&ms, b.img.width, b.img.height); + b.pad_sides(e, sides)?; + } let (w, h) = (b.img.width, b.img.height); let mut acc = e.image(w, h); let mut count = 0u32; - for k in 0..n { - let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; - let Some(pr) = host.params_at(t) else { continue }; + for pr in &samples { let op = pr.f("opacity") as f32 / 100.0; let empty = e.image(w, h); - let one = ops::warp(e, &empty, &b.img, &transform_matrix(&pr, &b), sampling, BlendMode::Normal, op, 0, None); + let one = ops::warp(e, &empty, &b.img, &transform_matrix(pr, &b), sampling, BlendMode::Normal, op, 0, None); let sum = e.image(w, h); e.pixels("fxs_add", &Params::default(), &acc, Some(&one), &sum, None); acc = sum; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 950c98549..92a9775c1 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -31,7 +31,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | | Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | -| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | +| Distort | 39 | 35 | Transform matches After Effects 2026 renders (skew after rotation, drawn outside the layer); Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | | Immersive Video | 12 | 12 | — | @@ -70,6 +70,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU | +| 2026-10-19 | minor | Transform: After Effects' skew order and output beyond the layer bounds, measured against After Effects 2026 renders of three cases (CPU and GPU) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | | 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls | | 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests | From b9e859fe3f6fb7a78070228bbb5d20943004a119 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 06:06:54 +0200 Subject: [PATCH 0180/1386] Masks: feather reaches into the layer from a mask outside it, as in After Effects The mask coverage was rasterised into the layer-sized buffer and then blurred, so coverage outside the layer counted as 0: a feathered mask past the layer edge faded at that edge, and one just outside the layer gave nothing (the comment said the buffer grew; nothing grew it). A feathered mask is now rasterised on a grid padded by the blur's whole support (3 sigma + 3 for the Gaussian, the box radius for a linear falloff), blurred there and cropped back to the buffer. Wide feathers (sigma > 32 px) use a grid coarser per axis so the blur spans ~16 grid pixels, which keeps the allocation near the buffer's size (feather 1000 on 1920x1080: a 160x134 grid instead of ~20 Mpx); the grid is capped, feathers are clamped and non-finite ones ignored. Unfeathered masks and masks inside the layer give the same coverage as before. The GPU uses these CPU masks (adjustment footprint, masked sources): no GPU change. Tests: case 398 of the .aep corpus rebuilt in the project model (red solid, adjustment layer with a default Gradient Ramp, a 1920x200 mask below the layer, feather 1000) within 3 levels of the Gaussian and within 14 of After Effects' render; a mask right of a solid, a mask larger than the layer (edges stay opaque), linear falloff, half resolution, and huge or broken feathers. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 124 +++++++++++++++-- crates/render/src/tests_mask_feather.rs | 178 ++++++++++++++++++++++++ 3 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_feather.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..8b9671e43 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2420,6 +2420,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_mask_feather; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index bb91ef71d..04a6e63e9 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -60,6 +60,86 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu coverage_at(ctx, layer, g, buf) } +/// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). +const MAX_FEATHER_PX: f64 = 1.0e6; +/// Up to this Gaussian sigma (box radius for a linear falloff: twice it) a feather is blurred at +/// the buffer's resolution. A wider one is blurred on a grid coarser per axis by the factor that +/// leaves it about `COARSE_SIGMA` grid pixels (2x for the box radius): a wide blur is smooth, so +/// the coarse grid loses nothing visible, and the padding stays about 50 grid pixels. +const FULL_RES_SIGMA: f64 = 32.0; +const COARSE_SIGMA: f64 = 16.0; +/// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back +/// to blurring within the buffer. +const MAX_GRID_PX: usize = 1 << 26; +/// Padding for a variable-width feather, which is rasterised at the buffer's resolution. +const MAX_VARIABLE_PAD: usize = 256; + +/// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per +/// grid pixel, `pad` grid pixels on each side of the buffer, and the blur in grid pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + step: usize, + pad: usize, + /// Gaussian sigma, or the box radius of a linear falloff. + blur: f64, +} + +impl Axis { + /// `feather` in buffer pixels. The pad covers the blur's whole support, so the coverage of + /// the path outside the buffer reaches into it as on an unbounded plane. + fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { + let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + if f <= 0.0 { + return Axis { step: 1, pad: 0, blur: 0.0 }; + } + if linear { + // One box pass: a straight ramp `feather` pixels wide across the edge. + let r = (f / 2.0).round().max(1.0); + let step = if full_res_only || r <= 2.0 * FULL_RES_SIGMA { 1 } else { (r / (2.0 * COARSE_SIGMA)).ceil() as usize }; + let rg = (r / step as f64).round().max(1.0); + let pad = rg as usize; + Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: rg } + } else { + let sigma = f / 2.0; + let step = if full_res_only || sigma <= FULL_RES_SIGMA { 1 } else { (sigma / COARSE_SIGMA).ceil() as usize }; + let sg = sigma / step as f64; + // Three box passes of radii summing to at most 1.5 * (sqrt(4 sigma^2 + 1) + 1) <= 3 sigma + 3. + let pad = (3.0 * sg).ceil() as usize + 3; + Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: sg } + } + } + + /// Grid pixels for `n` buffer pixels. + fn len(&self, n: u32) -> Option { + (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) + } +} + +/// Resamples a grid coverage (`gw` wide) back to the `w x h` buffer: bilinear between the grid +/// pixel centres (an exact copy when both steps are 1). +fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { + let (gw, gh) = (grid.width as usize, grid.height as usize); + let mut out = Mask::new(w, h, 0.0); + let at = |x: usize, y: usize| grid.data.get(y.saturating_mul(gw).saturating_add(x)).copied().unwrap_or(0.0); + // Grid coordinate of a buffer pixel centre, as (index, fraction) on that axis. + let map = |i: u32, a: Axis, n: usize| -> (usize, usize, f32) { + let u = (i as f64 + 0.5) / a.step as f64 + a.pad as f64 - 0.5; + let u = u.clamp(0.0, n.saturating_sub(1) as f64); + let i0 = u.floor() as usize; + (i0, (i0 + 1).min(n.saturating_sub(1)), (u - i0 as f64) as f32) + }; + let xs: Vec<(usize, usize, f32)> = (0..w).map(|x| map(x, ax, gw)).collect(); + out.data.par_chunks_mut(w.max(1) as usize).enumerate().for_each(|(y, row)| { + let (y0, y1, fy) = map(y as u32, ay, gh); + for (o, &(x0, x1, fx)) in row.iter_mut().zip(&xs) { + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + *o = top + (bottom - top) * fy; + } + }); + out +} + fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; let path = effectcraft_path::to_kurbo(&sp); @@ -70,29 +150,45 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + // The feather blurs the mask's own edge, also where the path lies outside the layer (After + // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is + // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. + // A variable feather scales its point radii by the mean scale: it stays at full resolution. + let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); + let blurred = ax.blur > 0.0 || ay.blur > 0.0; + let grid = match (ax.len(w), ay.len(h)) { + (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { + let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; + Some((gw, gh, ax, ay)) + } + _ => None, + }; + // Without the grid: blurred within the buffer at its resolution (the behaviour before the padding). + let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0] * buf.scale), unpadded(feather[1] * buf.scale))); + // Buffer pixels -> grid pixels. + let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); + let mg = to_grid * m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + effectcraft_path::feather::variable_feather_coverage(&sp, &mg, gw, gh, exp, linear) } else if exp.abs() > 0.01 { // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a // ring around an uncovered middle (growing) or islands (shrinking). let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); - effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &mg, gw, gh, FillRule::NonZero) } else { - effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); - if feather[0] > 0.0 || feather[1] > 0.0 { + if blurred { let img = cov.to_image(); - let b = if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = |f: f64| ((f * buf.scale / 2.0).round() as usize).max(if f > 0.0 { 1 } else { 0 }); - box_blur(&img, r(feather[0]), r(feather[1]), 1, false) - } else { - gaussian_blur(&img, feather[0] * buf.scale / 2.0, feather[1] * buf.scale / 2.0, false) - }; + let b = if linear { box_blur(&img, gx.blur as usize, gy.blur as usize, 1, false) } else { gaussian_blur(&img, gx.blur, gy.blur, false) }; cov = Mask::from_alpha(&b); } + if grid.is_some() { + cov = crop(&cov, gx, gy, w, h); + } Some(cov) } @@ -133,7 +229,9 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo if list.is_empty() { return false; } - // Masks may extend past the source: grow the buffer to cover them when feather/expansion pads. + // Masks may extend past the source: the buffer keeps its size (pixels outside the layer stay + // empty), but a feathered mask is blurred on a padded grid, so the feather of an edge outside + // the buffer still reaches into it (`coverage_at`). let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; diff --git a/crates/render/src/tests_mask_feather.rs b/crates/render/src/tests_mask_feather.rs new file mode 100644 index 000000000..52ad60c61 --- /dev/null +++ b/crates/render/src/tests_mask_feather.rs @@ -0,0 +1,178 @@ +//! A feathered mask blurs its own edge, also where the path lies outside the layer: the feather +//! of a mask partly or wholly outside the layer reaches into it (After Effects), instead of the +//! coverage stopping at the layer edge. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, FeatherFalloff, GroupKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) +} + +fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], linear: bool) { + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", rect, MaskMode::Add, [255, 255, 0]); + p.next_id = next; + m.get_mut("feather").unwrap().value = Value::Vec2(feather); + if linear && let GroupKind::Mask { feather_falloff, .. } = &mut m.kind { + *feather_falloff = FeatherFalloff::Linear; + } + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); +} + +/// The standard normal distribution function (Abramowitz and Stegun 7.1.26, error < 2e-7). +fn phi(x: f64) -> f64 { + let z = x.abs() / std::f64::consts::SQRT_2; + let t = 1.0 / (1.0 + 0.327_591_1 * z); + let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); + let erf = 1.0 - poly * (-z * z).exp(); + if x >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +} + +/// Coverage at pixel centre `x` of the span `a..b` blurred by a Gaussian of `sigma` (one axis). +fn span(x: f64, a: f64, b: f64, sigma: f64) -> f64 { + phi((b - x) / sigma) - phi((a - x) / sigma) +} + +/// A white 200×100 solid (32 bpc) with one Add mask. +fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) -> effectcraft_raster::Image { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0; 3], 200, 100); + add_mask(&mut p, &mut l, rect, feather, linear); + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, scale) +} + +#[test] +fn a_feathered_mask_outside_a_solid_reaches_into_it() { + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = Gaussian sigma 20. + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); + for x in [150i64, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 20.0) * span(50.5, -50.0, 150.0, 20.0); + let got = img.get(x, 50)[3] as f64; + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the Gaussian gives {want}"); + } + assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); + assert!(img.get(100, 50)[3] < 1e-3); + // Without feather the mask hides the layer. + let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); + assert!(hard.data.iter().all(|p| p[3] == 0.0)); + // A linear falloff ramps over the feather width: 20 px either side of the edge at x 210. + let lin = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], true, 1.0); + let a = lin.get(199, 50)[3]; + assert!((a - 0.24).abs() < 0.04, "linear: {a}"); + assert!(lin.get(185, 50)[3] < 0.01); + // At half resolution the same layer pixel gets the same coverage. + let half = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 0.5); + assert!((half.get(99, 25)[3] - img.get(198, 50)[3]).abs() < 0.02, "{} {}", half.get(99, 25)[3], img.get(198, 50)[3]); +} + +#[test] +fn a_feathered_mask_larger_than_the_layer_keeps_the_layer_edges() { + // The mask covers the layer and 100 px around it: the layer edges are inside the mask and + // stay opaque (they used to fade, as if the layer edge were the mask edge). + let img = white_with_mask(ShapePath::rect([100.0, 50.0], 400.0, 300.0), [40.0, 40.0], false, 1.0); + for (x, y) in [(0i64, 0i64), (0, 50), (199, 99), (100, 0)] { + assert!(img.get(x, y)[3] > 0.99, "({x}, {y}): {}", img.get(x, y)[3]); + } + // A mask edge inside the layer is feathered as before: half coverage on the edge. + let inner = white_with_mask(ShapePath::rect([100.0, 50.0], 100.0, 400.0), [40.0, 40.0], false, 1.0); + assert!((inner.get(50, 50)[3] - 0.5).abs() < 0.03, "{}", inner.get(50, 50)[3]); + assert!(inner.get(100, 50)[3] > 0.98); +} + +#[test] +fn huge_and_broken_feathers_stay_bounded() { + for f in [1.0e5, 1.0e9, f64::INFINITY, f64::NAN, -5.0] { + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, f], false, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "feather {f}"); + let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [f, 10.0], true, 1.0); + assert!(img.data.iter().all(|p| p[3].is_finite() && (0.0..=1.0).contains(&p[3])), "linear feather {f}"); + } +} + +/// Case 398 of the .aep corpus, built in EffectCraft's own model: a 1920×1080 comp at 8 bpc, a +/// red solid, above it an adjustment layer of comp size with a Gradient Ramp at its defaults and +/// one Add mask entirely below the layer (1920×200 centred at [960, 1250]) with feather 1000. +fn case_398(with_mask: bool) -> (Project, ItemId) { + let mut p = Project::default(); + assert_eq!(p.settings.bit_depth, BitDepth::Bpc8, "After Effects' default depth"); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + let mut adj = solid(&mut p, &comp, [1.0; 3], 1920, 1080); + adj.switches.adjustment = true; + let spec = effectcraft_effects::find("ec.generate.gradientramp").unwrap(); + let mut next = p.next_id; + let ramp = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + adj.props.sub_mut("effects").unwrap().children.push(ramp.into()); + if with_mask { + add_mask(&mut p, &mut adj, ShapePath::rect([960.0, 1250.0], 1920.0, 200.0), [1000.0, 1000.0], false); + } + let c = p.comp_mut(cid).unwrap(); + c.layers.push(adj); + c.layers.push(red); + (p, cid) +} + +fn rgb8(img: &effectcraft_raster::Image, x: i64, y: i64) -> [i32; 3] { + let px = img.get(x, y); + [0, 1, 2].map(|c| (px[c].clamp(0.0, 1.0) * 255.0).round() as i32) +} + +#[test] +fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { + let t = Tick::from_seconds_f64(1.0); + let (p, cid) = case_398(true); + let img = render_frame(&p, cid, t, 1.0); + // The ramp alone (no mask): what the adjustment layer mixes in. + let (q, qid) = case_398(false); + let ramp = render_frame(&q, qid, t, 1.0); + // After Effects 2026, 8 bpc, the render of 398 at 1 s. + let ae = [ + ((960, 0), [255, 0, 0]), + ((960, 300), [254, 0, 0]), + ((960, 540), [250, 5, 5]), + ((960, 800), [248, 20, 20]), + ((960, 1000), [252, 40, 40]), + ((960, 1079), [255, 47, 47]), + ((100, 1079), [255, 29, 29]), + ]; + let mut report = Vec::new(); + for ((x, y), want_ae) in ae { + let got = rgb8(&img, x, y); + // 1. The fix: the coverage is the mask's Gaussian (sigma = feather / 2 = 500), blurred from + // the mask edge outside the layer; the result is the red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 500.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 500.0); + let r = ramp.get(x, y); + let want = [0, 1, 2].map(|k| { + let red = if k == 0 { 1.0 } else { 0.0 }; + ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 + }); + for k in 0..3 { + assert!((got[k] - want[k]).abs() <= 3, "({x}, {y}): {got:?}, the Gaussian coverage {c:.4} gives {want:?}"); + } + // 2. After Effects: the same shape of falloff, the feather reaching ~500 px into the layer. + // Its kernel is narrower than sigma = feather / 2 (the AE values fit sigma ~ 350), so + // near the mask EffectCraft is lower and far from it higher: 12 levels apart at most + // (green at the bottom edge), allowed 14. + for k in 0..3 { + assert!((got[k] - want_ae[k]).abs() <= 14, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + } + report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); + } + println!("398: {}", report.join("; ")); + // The mask covers nothing of the layer itself, but its feather does: the top stays (nearly) red. + assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); + assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); +} From 25de4528042643380577e01ad532561139310d9f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 18 Oct 2026 07:08:53 +0200 Subject: [PATCH 0181/1386] Masks: feather falloff as After Effects The Smooth feather is three separable box passes of 2 round(0.37 F) + 1 pixels (a quadratic B-spline reaching ~1.11 F either side), fitted to After Effects 2026 renders of feather 100, 300 and 1000 (rows, columns, corner diagonal, case 398): max error under 1/255, where a Gaussian of any width misses by 3/255 or more (it was sigma = F / 2). The coarse grid for wide feathers picks the step whose three windows add up closest to the full-resolution ones. Linear is unchanged (unmeasured). Co-Authored-By: Claude Opus 5.5 --- crates/project/src/props.rs | 3 +- crates/render/src/masks.rs | 88 +++++++++++------ crates/render/src/tests_mask_feather.rs | 121 +++++++++++++++++++----- 3 files changed, 159 insertions(+), 53 deletions(-) diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index 6212a4b59..be2468c37 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -205,7 +205,8 @@ pub enum MaskMotionBlur { Off, } -/// How a mask's feather ramps: Smooth (Gaussian) or Linear. +/// How a mask's feather ramps: Smooth (three box passes, an S-shaped ramp of finite reach, as +/// measured in After Effects) or Linear (one box pass: a straight ramp). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FeatherFalloff { #[default] diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 04a6e63e9..f0e0eeee0 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -5,7 +5,7 @@ use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; -use effectcraft_raster::{Mask, box_blur, gaussian_blur}; +use effectcraft_raster::{Mask, box_blur}; use rayon::prelude::*; use crate::eval::EvalCtx; @@ -62,12 +62,20 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu /// The largest feather taken, in buffer pixels (larger and non-finite values are clamped). const MAX_FEATHER_PX: f64 = 1.0e6; -/// Up to this Gaussian sigma (box radius for a linear falloff: twice it) a feather is blurred at -/// the buffer's resolution. A wider one is blurred on a grid coarser per axis by the factor that -/// leaves it about `COARSE_SIGMA` grid pixels (2x for the box radius): a wide blur is smooth, so -/// the coarse grid loses nothing visible, and the padding stays about 50 grid pixels. -const FULL_RES_SIGMA: f64 = 32.0; -const COARSE_SIGMA: f64 = 16.0; +/// Smooth falloff (After Effects' default), measured on After Effects 2026 renders (feather 100, +/// 300 and 1000; red value along rows, columns and the corner diagonal of a feathered rect mask): +/// the edge is blurred separably by three box passes, each `2r + 1` pixels wide with +/// `r = round(SMOOTH_BOX_RADIUS * feather)`. That is a quadratic B-spline reaching about +/// 1.11 x feather either side of the path (finite support; a Gaussian of any width misses the +/// measured profile by 3/255 or more, this kernel by less than 1/255). +const SMOOTH_BOX_RADIUS: f64 = 0.37; +/// Up to this box radius (buffer pixels) a feather is blurred at the buffer's resolution. A wider +/// one is blurred on a grid coarser per axis, about `COARSE_RADIUS` grid pixels per box: a wide +/// blur is smooth, so the coarse grid loses nothing visible, and the padding stays about +/// 3 x `COARSE_RADIUS` grid pixels. (Box passes cost the same at any radius; the coarse grid +/// bounds the padded area, i.e. memory.) +const FULL_RES_RADIUS: usize = 48; +const COARSE_RADIUS: usize = 32; /// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back /// to blurring within the buffer. const MAX_GRID_PX: usize = 1 << 26; @@ -75,13 +83,13 @@ const MAX_GRID_PX: usize = 1 << 26; const MAX_VARIABLE_PAD: usize = 256; /// One axis of the grid a feathered mask is rasterised and blurred on: `step` buffer pixels per -/// grid pixel, `pad` grid pixels on each side of the buffer, and the blur in grid pixels. +/// grid pixel, `pad` grid pixels on each side of the buffer, and the box radii in grid pixels. #[derive(Clone, Copy, Debug, PartialEq)] struct Axis { step: usize, pad: usize, - /// Gaussian sigma, or the box radius of a linear falloff. - blur: f64, + /// Radii of the box passes (Smooth: three; Linear: one, the others 0). + radii: [usize; 3], } impl Axis { @@ -89,24 +97,43 @@ impl Axis { /// the path outside the buffer reaches into it as on an unbounded plane. fn new(feather: f64, linear: bool, full_res_only: bool) -> Axis { let f = if feather.is_finite() { feather.clamp(0.0, MAX_FEATHER_PX) } else { 0.0 }; + let none = Axis { step: 1, pad: 0, radii: [0; 3] }; if f <= 0.0 { - return Axis { step: 1, pad: 0, blur: 0.0 }; + return none; } + let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; if linear { - // One box pass: a straight ramp `feather` pixels wide across the edge. - let r = (f / 2.0).round().max(1.0); - let step = if full_res_only || r <= 2.0 * FULL_RES_SIGMA { 1 } else { (r / (2.0 * COARSE_SIGMA)).ceil() as usize }; - let rg = (r / step as f64).round().max(1.0); - let pad = rg as usize; - Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: rg } - } else { - let sigma = f / 2.0; - let step = if full_res_only || sigma <= FULL_RES_SIGMA { 1 } else { (sigma / COARSE_SIGMA).ceil() as usize }; - let sg = sigma / step as f64; - // Three box passes of radii summing to at most 1.5 * (sqrt(4 sigma^2 + 1) + 1) <= 3 sigma + 3. - let pad = (3.0 * sg).ceil() as usize + 3; - Axis { step, pad: if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }, blur: sg } + // Linear falloff (not measured against After Effects; kept as it was): one box pass, + // a straight ramp `feather` pixels wide across the edge. + let r = (f / 2.0).round().max(1.0) as usize; + let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; + let rg = ((r as f64 / step as f64).round() as usize).max(1); + return Axis { step, pad: cap(rg), radii: [rg, 0, 0] }; } + let r = (SMOOTH_BOX_RADIUS * f).round() as usize; + if r == 0 { + return none; + } + if full_res_only || r <= FULL_RES_RADIUS { + return Axis { step: 1, pad: cap(3 * r + 1), radii: [r; 3] }; + } + // On a coarse grid the three windows (2 rg + 1 grid pixels each) should still add up to + // 3 (2r + 1) buffer pixels: of a few steps take the one that comes closest, and spread + // the radii over the passes. + let want = 3 * (2 * r + 1); + let s0 = r.div_ceil(COARSE_RADIUS); + let fit = |s: usize| -> (usize, usize) { + let sum = ((want as f64 / s as f64 - 3.0) / 2.0).round().max(0.0) as usize; + let got = (2 * sum + 3).saturating_mul(s); + (sum, got.abs_diff(want)) + }; + let (step, sum) = (s0..=s0 + (s0 / 2).min(8)).map(|s| (s, fit(s))).min_by_key(|&(s, (_, e))| (e, s)).map_or((s0, fit(s0).0), |(s, (sum, _))| (s, sum)); + let radii = [0, 1, 2].map(|i| sum / 3 + usize::from(i < sum % 3)); + Axis { step, pad: sum + 1, radii } + } + + fn blurs(&self) -> bool { + self.radii.iter().any(|&r| r > 0) } /// Grid pixels for `n` buffer pixels. @@ -156,7 +183,7 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. // A variable feather scales its point radii by the mean scale: it stays at full resolution. let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); - let blurred = ax.blur > 0.0 || ay.blur > 0.0; + let blurred = ax.blurs() || ay.blurs(); let grid = match (ax.len(w), ay.len(h)) { (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; @@ -182,9 +209,14 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mg, gw, gh, FillRule::NonZero) }; if blurred { - let img = cov.to_image(); - let b = if linear { box_blur(&img, gx.blur as usize, gy.blur as usize, 1, false) } else { gaussian_blur(&img, gx.blur, gy.blur, false) }; - cov = Mask::from_alpha(&b); + // Separable box passes (the corner of a feathered rect is the product of its edges). + let mut img = cov.to_image(); + for (rx, ry) in gx.radii.into_iter().zip(gy.radii) { + if rx > 0 || ry > 0 { + img = box_blur(&img, rx, ry, 1, false); + } + } + cov = Mask::from_alpha(&img); } if grid.is_some() { cov = crop(&cov, gx, gy, w, h); diff --git a/crates/render/src/tests_mask_feather.rs b/crates/render/src/tests_mask_feather.rs index 52ad60c61..db04b1f9f 100644 --- a/crates/render/src/tests_mask_feather.rs +++ b/crates/render/src/tests_mask_feather.rs @@ -26,18 +26,28 @@ fn add_mask(p: &mut Project, l: &mut Layer, rect: ShapePath, feather: [f64; 2], l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); } -/// The standard normal distribution function (Abramowitz and Stegun 7.1.26, error < 2e-7). -fn phi(x: f64) -> f64 { - let z = x.abs() / std::f64::consts::SQRT_2; - let t = 1.0 / (1.0 + 0.327_591_1 * z); - let poly = t * (0.254_829_592 + t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429)))); - let erf = 1.0 - poly * (-z * z).exp(); - if x >= 0.0 { 0.5 * (1.0 + erf) } else { 0.5 * (1.0 - erf) } +/// Coverage at signed distance `d` inside an edge blurred by three box passes `w` pixels wide +/// (the Smooth falloff: an Irwin-Hall n = 3 distribution function, support +-1.5 w). +fn edge(d: f64, w: f64) -> f64 { + let t = (d / w + 1.5).clamp(0.0, 3.0); + if t < 1.0 { + t * t * t / 6.0 + } else if t < 2.0 { + (-2.0 * t * t * t + 9.0 * t * t - 9.0 * t + 3.0) / 6.0 + } else { + 1.0 - (3.0 - t).powi(3) / 6.0 + } } -/// Coverage at pixel centre `x` of the span `a..b` blurred by a Gaussian of `sigma` (one axis). -fn span(x: f64, a: f64, b: f64, sigma: f64) -> f64 { - phi((b - x) / sigma) - phi((a - x) / sigma) +/// The box window of the Smooth falloff for `feather` pixels: `2 round(0.37 feather) + 1`. +fn window(feather: f64) -> f64 { + 2.0 * (0.37 * feather).round() + 1.0 +} + +/// Coverage at pixel centre `x` of the span `a..b` feathered by `feather` (one axis). +fn span(x: f64, a: f64, b: f64, feather: f64) -> f64 { + let w = window(feather); + edge(x - a, w) + edge(b - x, w) - 1.0 } /// A white 200×100 solid (32 bpc) with one Add mask. @@ -54,15 +64,16 @@ fn white_with_mask(rect: ShapePath, feather: [f64; 2], linear: bool, scale: f64) #[test] fn a_feathered_mask_outside_a_solid_reaches_into_it() { - // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = Gaussian sigma 20. + // The mask spans x 210..260, right of the layer (x 0..200); feather 40 = three 31 px boxes. let img = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [40.0, 40.0], false, 1.0); - for x in [150i64, 180, 199] { - let want = span(x as f64 + 0.5, 210.0, 260.0, 20.0) * span(50.5, -50.0, 150.0, 20.0); + for x in [150i64, 170, 180, 199] { + let want = span(x as f64 + 0.5, 210.0, 260.0, 40.0) * span(50.5, -50.0, 150.0, 40.0); let got = img.get(x, 50)[3] as f64; - assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the Gaussian gives {want}"); + assert!((got - want).abs() < 0.01, "x {x}: alpha {got}, the box kernel gives {want}"); } assert!(img.get(199, 50)[3] > 0.25, "the feather reaches the layer edge"); - assert!(img.get(100, 50)[3] < 1e-3); + // Finite support: nothing beyond 1.5 windows (46.5 px) from the edge. + assert!(img.get(160, 50)[3] == 0.0 && img.get(100, 50)[3] == 0.0); // Without feather the mask hides the layer. let hard = white_with_mask(ShapePath::rect([235.0, 50.0], 50.0, 200.0), [0.0, 0.0], false, 1.0); assert!(hard.data.iter().all(|p| p[3] == 0.0)); @@ -151,23 +162,21 @@ fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { let mut report = Vec::new(); for ((x, y), want_ae) in ae { let got = rgb8(&img, x, y); - // 1. The fix: the coverage is the mask's Gaussian (sigma = feather / 2 = 500), blurred from - // the mask edge outside the layer; the result is the red mixed toward the ramp by it. - let c = span(x as f64 + 0.5, 0.0, 1920.0, 500.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 500.0); + // 1. The coverage is the mask's Smooth falloff (three 741 px boxes for feather 1000), + // blurred from the mask edge outside the layer (on the coarse grid); the result is the + // red mixed toward the ramp by it. + let c = span(x as f64 + 0.5, 0.0, 1920.0, 1000.0) * span(y as f64 + 0.5, 1150.0, 1350.0, 1000.0); let r = ramp.get(x, y); let want = [0, 1, 2].map(|k| { let red = if k == 0 { 1.0 } else { 0.0 }; ((red + (r[k] as f64 - red) * c) * 255.0).round() as i32 }); for k in 0..3 { - assert!((got[k] - want[k]).abs() <= 3, "({x}, {y}): {got:?}, the Gaussian coverage {c:.4} gives {want:?}"); + assert!((got[k] - want[k]).abs() <= 2, "({x}, {y}): {got:?}, the box coverage {c:.4} gives {want:?}"); } - // 2. After Effects: the same shape of falloff, the feather reaching ~500 px into the layer. - // Its kernel is narrower than sigma = feather / 2 (the AE values fit sigma ~ 350), so - // near the mask EffectCraft is lower and far from it higher: 12 levels apart at most - // (green at the bottom edge), allowed 14. + // 2. After Effects: within 2 levels per channel. for k in 0..3 { - assert!((got[k] - want_ae[k]).abs() <= 14, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); + assert!((got[k] - want_ae[k]).abs() <= 2, "({x}, {y}): EffectCraft {got:?}, After Effects {want_ae:?}"); } report.push(format!("({x},{y}) ec {got:?} ae {want_ae:?}")); } @@ -176,3 +185,67 @@ fn case_398_a_feathered_mask_below_an_adjustment_layer_reaches_into_it() { assert!(rgb8(&img, 960, 0)[0] >= 253 && rgb8(&img, 960, 0)[1] == 0); assert!(rgb8(&img, 960, 1079)[1] > 25, "the feather reaches the bottom edge"); } + +/// After Effects 2026 (sRGB, 32 bpc): a red 1920x1080 solid with a 1000x600 Add mask centred at +/// [960, 540] (left edge at x 460), feather `f` on both axes; coverage along the row y 540 from +/// x 160 to 760 in steps of 20 (8-bit values / 255). +const AE_FEATHER_100: [(i64, f64); 11] = + [(360, 0.0), (380, 0.016), (400, 0.059), (420, 0.153), (440, 0.31), (460, 0.506), (480, 0.698), (500, 0.855), (520, 0.945), (540, 0.988), (560, 1.0)]; +const AE_FEATHER_300: [(i64, f64); 31] = [ + (160, 0.0), + (180, 0.004), + (200, 0.008), + (220, 0.012), + (240, 0.024), + (260, 0.035), + (280, 0.055), + (300, 0.078), + (320, 0.11), + (340, 0.149), + (360, 0.192), + (380, 0.247), + (400, 0.306), + (420, 0.369), + (440, 0.435), + (460, 0.502), + (480, 0.569), + (500, 0.635), + (520, 0.698), + (540, 0.757), + (560, 0.808), + (580, 0.851), + (600, 0.89), + (620, 0.922), + (640, 0.945), + (660, 0.965), + (680, 0.976), + (700, 0.988), + (720, 0.992), + (740, 0.996), + (760, 1.0), +]; + +#[test] +fn the_smooth_feather_falloff_matches_after_effects() { + for (f, ae) in [(100.0, &AE_FEATHER_100[..]), (300.0, &AE_FEATHER_300[..])] { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080); + add_mask(&mut p, &mut l, ShapePath::rect([960.0, 540.0], 1000.0, 600.0), [f, f], false); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let mut worst = 0.0f64; + for &(x, want) in ae { + let got = img.get(x, 540)[3] as f64; + // Tolerance 2/255 on the 8-bit After Effects values. + assert!((got - want).abs() <= 2.0 / 255.0, "feather {f}, x {x}: {got:.4}, After Effects {want}"); + worst = worst.max((got - want).abs()); + } + // The corner is the product of its two edges (a separable blur): 0.25 for both feathers. + let corner = img.get(460, 240)[3] as f64; + assert!((corner - 0.251).abs() <= 2.0 / 255.0, "feather {f}: corner {corner}"); + println!("feather {f}: largest deviation from After Effects {:.2}/255", worst * 255.0); + } +} From 710ffd9f7e921320f899ccbab2edd4f63b36be1b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:15:53 +0200 Subject: [PATCH 0182/1386] Shapes: Repeater copy opacity steps 1 / Copies toward End Opacity, as After Effects After Effects gives Repeater copy i the opacity Start + (End - Start) * i / Copies, so the last copy stops one step short of End Opacity. EffectCraft reached End Opacity at the last copy (i / (Copies - 1)). Measured in AE 2026 (case 709: 3 copies, End Opacity 40): copies at 100 / 80 / 60 % (EffectCraft 100 / 70 / 40); the overlap of the two copies 92 % (EffectCraft 82 %). Now within 2/255 at the sampled pixels. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/shapes.rs | 5 ++++- crates/render/src/shapes/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..641382440 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -395,7 +395,10 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; + // Copy i's opacity moves 1 / Copies of the way from Start to End Opacity per + // copy, so the last copy stops one step short of End Opacity (After Effects + // 2026, case 709: 3 copies, End Opacity 40 give 100 %, 80 %, 60 %). + let t = if copies > 0.0 { (i as f64 / copies) as f32 } else { 0.0 }; let opk = so + (eo - so) * t; let partial = if i + 1 == n && copies.fract() > 0.0 { copies.fract() as f32 } else { 1.0 }; for d in &base_draws { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index 1dbf67a7a..17591f310 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -150,6 +150,28 @@ fn operators_below_repeater_act_on_copies() { assert!((coverage(&img) - 3.0 * 24.0 * 24.0).abs() < 30.0, "{}", coverage(&img)); } +/// After Effects 2026 (case 709): a 400 × 200 rectangle with Fill, then a Repeater of 3 copies +/// (Position 150, 60, Rotation 10, Scale 90 / 80, End Opacity 40) gives the copies 100 %, 80 % +/// and 60 % opacity (EffectCraft gave 70 % and 40 %). The points are comp pixels where AE shows +/// only the last copy, only the middle one, and both. +#[test] +fn repeater_copy_opacity_matches_after_effects() { + let (p, cid) = shape_comp_sized(1920, 1080, |ids| { + let mut rep = build::shape_repeater(ids, 3.0, [150.0, 60.0]); + set(&mut rep, "transform/rotation", Value::Scalar(10.0)); + set(&mut rep, "transform/scale", Value::Vec2([90.0, 80.0])); + set(&mut rep, "transform/endOpacity", Value::Scalar(40.0)); + let items = vec![build::shape_rect(ids, [400.0, 200.0], [0.0, 0.0], 0.0), build::shape_fill(ids, [0.0, 1.0, 0.0, 1.0]), rep]; + vec![build::shape_group(ids, "Group 1", items)] + }); + let img = frame(&p, cid); + // AE's green over its red layer, 153, 153, 204, 235 and 255 of 255. + for (x, y, want) in [(1360, 700, 0.6), (1300, 620, 0.6), (1220, 560, 0.8), (1180, 630, 0.92), (1100, 600, 1.0)] { + let a = alpha(&img, x, y); + assert!((a - want).abs() < 2.0 / 255.0, "({x}, {y}): {a} vs AE {want}"); + } +} + #[test] fn repeater_then_merge_unites_copies() { let (p, cid) = shape_comp(|ids| { From d57b73d4cbe841f795c160b88d7da96ec5533312 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:25:48 +0200 Subject: [PATCH 0183/1386] Shape rectangles start at the top-right, as After Effects, so Trim Paths cut the same segment After Effects draws a Rectangle Path clockwise from its top-right corner, or with round corners from the end of the top-right arc on the right edge; Trim Paths measures from there (AE 2026, case 930: 400 x 300 rectangles, Trim 0-25 % and 70 / 30). EffectCraft built the path with kurbo, starting at the top-left corner, so trimmed rectangles drew another segment: case 930 was 86 of 5184 grid points off (max 255), now 5 at stroke edges. Start > End was already drawn swapped, as in AE. Convert to Bezier now gives AE's vertex order (top-right first); the engine test follows. Lottie rectangles share the path and now start where lottie players start them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_stubs.rs | 5 ++- crates/path/src/lib.rs | 64 ++++++++++++++++++++++++++++++-- docs/fidelity/README.md | 10 +++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 84321ee7e..b36ad77be 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() { }; let radius = |v: [f64; 2]| v[0].hypot(v[1]); - // A 120 x 80 rectangle is its four corners, joined by straight edges. + // A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right + // corner clockwise as After Effects draws it. let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]})); - assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]); + assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]); assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}"); // Undo brings the parametric shape back. s.execute("edit.undo", json!({})).unwrap(); diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 249eb9fdd..9445da346 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -130,11 +130,36 @@ pub fn from_kurbo(path: &BezPath) -> Vec { out } -/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). +/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it +/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with +/// round corners from the end of the top-right arc on the right edge (After Effects 2026, +/// 400 × 300 rectangles trimmed 0–25 %, case 930). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { - let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0); - if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } + let (w, h) = (size[0].abs(), size[1].abs()); + let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0); + let rad = if roundness.is_finite() { roundness.clamp(0.0, w.min(h) / 2.0) } else { 0.0 }; + let mut p = BezPath::new(); + if rad <= 0.0 { + p.move_to((x1, y0)); + p.line_to((x1, y1)); + p.line_to((x0, y1)); + p.line_to((x0, y0)); + p.close_path(); + return p; + } + // Quarter circles as cubics (handle 0.5523 × radius). + let k = rad * 0.552_284_749_8; + p.move_to((x1, y0 + rad)); + p.line_to((x1, y1 - rad)); + p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1)); + p.line_to((x0 + rad, y1)); + p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad)); + p.line_to((x0, y0 + rad)); + p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0)); + p.line_to((x1 - rad, y0)); + p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad)); + p.close_path(); + p } pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { @@ -354,6 +379,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + /// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain: + /// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom. + /// Round corners 40: from the end of the top-right arc (200, −110) down, round the + /// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %. + #[test] + fn rectangles_start_where_after_effects_starts_them() { + let ends = |p: &BezPath| { + let mut first = None; + let mut last = None; + for el in p.elements() { + match *el { + PathEl::MoveTo(q) if first.is_none() => first = Some(q), + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q), + PathEl::ClosePath => {} + } + } + (first.unwrap_or_default(), last.unwrap_or_default()) + }; + let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0; + let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&plain[0]); + assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}"); + let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0); + let (a, b) = ends(&round[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}"); + // Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left). + let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0); + let (a, b) = ends(&swapped[0]); + assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}"); + } + #[test] fn roundtrip_vertex_model() { let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..fc780016b 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,16 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Rectangle path start point + +Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim +Paths: a plain rectangle starts at its top-right corner and runs clockwise (0–25 % covers the right +edge and 50 px of the bottom); with Roundness 40 it starts where the top-right arc meets the right +edge. Start 70 / End 30 draws 30–70 %. EffectCraft started at the top-left corner (and the rounded +rectangle elsewhere), so trimmed rectangles drew the wrong segment (86 of 5184 grid points off, +now 5 at stroke edges). `effectcraft_path::rect` now draws AE's order, which also gives Convert to +Bezier AE's vertex order. Regression test: `rectangles_start_where_after_effects_starts_them`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From a7255f76085e31290d48c129e628c9d8db0a0abe Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 01:36:25 +0200 Subject: [PATCH 0184/1386] Text: box text's first baseline is the height of "d" below the box top, as After Effects After Effects puts the first baseline of paragraph (box) text the height of the font's lowercase "d" (in whole thousandths of an em) below the box top, at the text's nominal size; superscript keeps the line there and only raises its glyphs. EffectCraft used the font ascent of the drawn size plus the superscript rise. Measured in AE 2026 (case 818, box at y -200): Times New Roman 100 px -130.50 (EffectCraft -110.9), 50 px -165.25, superscript -163.80 (EffectCraft drew it 15.8 px lower), Arial 100 px -128.40, superscript -161.70; now within 0.15 px. 517 (four wrapped lines, Times New Roman superscript): the line bands now land on AE's rows exactly (336-388, 434-486, 532-584, 643-682; were 16 px lower). A face without a "d" outline, and vertical type (not measured), keep the font ascent. The paragraph-box test now expects the caret (full ascent) to start a little above the box. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 68 +++++++++++++++++++++++++++++++++++++-- crates/text/src/lib.rs | 4 ++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1ae..0ec5c7347 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,22 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + let h = em * px; + (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +591,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -611,6 +630,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let st = &runs[si].1; fonts::face(primaries[si].face).metrics(st.glyph_size()) }; + let style_first = |si: usize| { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) + }; let eff_align = |last: bool| -> Align { if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } @@ -630,6 +653,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang: (0.0, 0.0), ascent: pm.ascent, descent: pm.descent, + first: style_first(si), advance: runs[si].1.line_advance(), align: eff_align(true), avail: avail_for(true), @@ -899,7 +923,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar carets.sort_by_key(|c| c.0); carets.dedup_by_key(|c| c.0); // Line metrics: the tallest face and the largest leading on the line. - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect(); seen.sort_unstable(); seen.dedup(); @@ -912,6 +936,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(runs[si].1.line_advance()); + first = first.max(style_first(si)); } for g in &glyphs { if g.face != primaries[g.run].face { @@ -930,6 +955,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, @@ -985,7 +1011,9 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.iter().any(|r| r.1.vertical); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; @@ -1213,6 +1241,42 @@ mod tests { assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01)); } + /// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole + /// thousandths of an em) below the box top, at the nominal size also for superscript, whose + /// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the + /// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the + /// superscript glyphs 15.8 px low. + #[test] + fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 187167527..71db4a9ea 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); From 8bc57bc7ad23d822ff3096fd20f3ed14b2616742 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:38:11 +0200 Subject: [PATCH 0185/1386] Text: Justify All balances the lines with the every-line composer, as After Effects With Justify All After Effects stretches the paragraph's last line too, and its every-line composer breaks the paragraph so that line is not a lone word: a 400 px box that the other justifications break into 6 / 6 / 1 words breaks 4 / 4 / 4 (AE 2026, EXPORTER read-back t6). EffectCraft's composer left the last line's slack out of its cost, so "twelve" stood alone on a stretched line; it now counts that slack for Justify All and every word starts within 1 px of AE's (what remains in that box is the known box-text baseline offset). Other justifications and the single-line composer are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 24 ++++++++++++++++++++++-- docs/fidelity/README.md | 9 +++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ec5c7347..d8bdee4fd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -761,7 +761,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let opps: Vec = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect(); let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX); if para.every_line && opps.len() > 1 && opps.len() <= 1500 { - // Minimise the summed squared slack of every line but the last. + // Minimise the summed squared slack of every line but the last; with Justify All + // the last line is stretched too, so its slack counts (After Effects then balances + // the lines: 4 / 4 / 4 words where the other modes break 6 / 6 / 1). + let last_counts = para.align == Align::Justify && para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -781,7 +784,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -1464,6 +1467,23 @@ mod tests { assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5)); } + /// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that + /// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the + /// stretched last line is not a lone word; word positions then match AE to 1 px. + #[test] + fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); + } + #[test] fn hanging_punctuation_overhangs_the_box() { let s = st(40.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..6c59acd43 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,15 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 83b134cdc8650e0324ab125f9f139fb77e964eac Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:43:27 +0200 Subject: [PATCH 0186/1386] Text: right-to-left point text with a justified paragraph sits flush right, as After Effects After Effects 2026 lays out right-to-left point text whose paragraph carries a justified code (justify last left / centre / right, justify all) flush at its start, the right, like right-aligned text (EXPORTER read-back t7: point rows with codes 3-6 at -152..-8 from the anchor). EffectCraft placed it by the last-line side (left / centre / left). Left-to-right point text and box text are unchanged; AE's slightly wider right-to-left box wrapping (1-2 px narrower word gaps) stays open. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 25 ++++++++++++++++++++++++- docs/fidelity/README.md | 10 ++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index d8bdee4fd..3ce08b84f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -635,7 +635,11 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) }; let eff_align = |last: bool| -> Align { - if (last || width.is_none()) && para.align == Align::Justify { + if rtl_para && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -1499,6 +1503,25 @@ mod tests { assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right"); } + /// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified + /// code (justify last left / centre / right, justify all) sits flush right at its anchor, as + /// right-aligned text does. + #[test] + fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } + } + #[test] fn rtl_paragraph_carets_run_right_to_left() { let text = "\u{5d0}\u{5d1}\u{5d2}"; diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 6c59acd43..d4c1bf788 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -80,6 +80,16 @@ every-line composer ignored the last line's slack and left "twelve" alone on a s counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: `justify_all_balances_the_lines_with_the_every_line_composer`. +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8ea876914dfc0eb6c87bddecf7afa5dfd28dfdd8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:22:04 +0200 Subject: [PATCH 0187/1386] Text animators: Tracking Amount in pixels, Subtract selectors stop at 0, as After Effects A text animator's Tracking Amount is in pixels in After Effects 2026, not in 1/1000 em like the Character panel's tracking: Amount 30 on every character adds 30 px between neighbours (half before, half after each) at 80 px and at 40 px. And a Subtract range selector over characters the first selector leaves out keeps them at 0 instead of selecting them negatively. With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2-4 by index, Subtract over the last 20 %) puts every glyph where AE does (it ended 88 px short, 1553 vs 1641; now 1641). The "Tracking In" text preset animates 40 -> 0 px (was 400 -> 0 thousandths of an em, about the same at 100 px text); Lottie export writes a style run's tracking (1/1000 em) as pixels for its tracking animator, which Lottie players add in pixels. Co-Authored-By: Claude Opus 5.5 --- crates/engine/presets/text_animators.json | 2 +- crates/lottie/src/text.rs | 4 +-- crates/render/src/text.rs | 8 +++--- crates/render/src/text/tests.rs | 33 +++++++++++++++++++---- crates/text/src/selectors.rs | 7 +++++ docs/fidelity/README.md | 12 +++++++++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index 125835c2b..e9de3d328 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { props.insert("s".into(), k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { - // Lottie tracking is in 1/1000 em like the Character panel's. - props.insert("t".into(), k(json!(s.tracking - base.tracking))); + // An animator's tracking is in pixels; the Character panel's in 1/1000 em. + props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); } if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..302f31162 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -639,10 +639,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; - let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0; - pen += x.track_before * em; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. + pen += x.track_before; shift[gi] = pen; - pen += x.track_after * em + vadv[gi]; + pen += x.track_after + vadv[gi]; if x.line_anchor_k > la_k { la = x.line_anchor; la_k = x.line_anchor_k; diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..760bd2fff 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -202,9 +202,9 @@ fn tracking_line_anchor_and_character_offset() { animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]); p.comp_mut(cid).unwrap().layers.push(l); let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0)); + set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -231,6 +231,29 @@ fn tracking_line_anchor_and_character_offset() { assert_eq!(substitute('a', &CharXf { char_value: Some((66.0, 1.0)), ..Default::default() }), Some('B')); } +/// After Effects 2026: a text animator's Tracking Amount is in pixels: Amount 30 on every +/// character of an 80 px and of a 40 px text adds 30 px between neighbouring characters (half +/// before, half after each one). +#[test] +fn animator_tracking_is_in_pixels_as_in_after_effects() { + for size in [80.0, 40.0] { + let x_of = |amount: f64| { + let (mut p, cid, comp) = setup(900, 200); + let mut l = text_layer(&mut p, &comp, doc("HHHH", size)); + animator(&mut p, &mut l, &["tracking"], &["range"]); + set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount)); + p.comp_mut(cid).unwrap().layers.push(l); + let paths = glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]); + paths.iter().map(|g| bounds_of(std::slice::from_ref(g)).x0).collect::>() + }; + let (a, b) = (x_of(0.0), x_of(30.0)); + for i in 1..a.len() { + let gap = (b[i] - b[i - 1]) - (a[i] - a[i - 1]); + assert!((gap - 30.0).abs() < 0.5, "{size} px: neighbour gap grows by {gap}"); + } + } +} + /// Without a Line Anchor, tracking grows each line from where its paragraph's alignment pins it: /// left text grows right, centred text around its centre, right text to the left (#146). #[test] @@ -244,10 +267,10 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; - let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0))); + let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1]; assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]); } @@ -259,7 +282,7 @@ fn tracking_grows_from_each_paragraphs_alignment() { let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0); [bounds_of(&a), bounds_of(&b)] }; - let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0))); + let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0))); assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0); assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1); } diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..0306b7e09 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,10 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +356,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index d4c1bf788..22fbd4330 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -90,6 +90,18 @@ Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_star AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in left-to-right text with the same font), so a 300 px box fits a word more per line. +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie export writes a style run's +tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From 8b64074ce367b980eab881feb4910aa4ec5a699b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 14:56:52 +0200 Subject: [PATCH 0188/1386] Text: per-character 3D rotation turns Z first, as a 3D layer and After Effects With per-character 3D on, After Effects turns each character by Rx * Ry * Rz (Z first), as it turns a 3D layer's X / Y / Z Rotation (case 931, AE 2026: "H" at 400 px with animator X / Y / Z Rotation 40 / 30 / 20). EffectCraft composed Rz * Ry * Rx, so the glyph faced another way: 23 of 5184 grid points off (max 255), now 3 at the anti-aliased edge (mean 0.015/255). The same order fix-orientation uses for layers; texts with one rotation axis are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 4 ++-- crates/render/src/text/tests.rs | 31 +++++++++++++++++++++++++++++++ docs/fidelity/README.md | 8 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 302f31162..c16419af8 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -752,9 +752,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 760bd2fff..98ef3ce14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -484,6 +484,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 22fbd4330..db579e257 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -102,6 +102,14 @@ preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie exp tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, `modes_combine`. +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` From d9205fa5f499a4df02c0696db46bff7072523cb0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 15:36:29 +0200 Subject: [PATCH 0189/1386] Curves: a natural cubic spline through the points, as After Effects After Effects' Curves effect interpolates its points with a natural cubic spline (zero curvature at the ends), clamped to 0..1: AE 2026 renders of an RGB curve (0,0) (125,199) (255,255) and a Red curve (0,0) (128,44) (255,255) (cases 962 / 963, ROUND4) give exactly the natural spline's values on every grey step (0, 30, 59, 115, 164, 202, 227, 242, 250, 255 and 0, 0, 1, 6, 19, 44, 83, 135, 194, 255). EffectCraft used a monotone (Fritsch-Carlson) spline: 34 / 67 / 125 / 171 ... on the RGB curve, up to 10/255 off (ROUND4 measured up to 6 levels on real projects); now within 1/255, CPU and GPU (the same Hermite tables carry the spline's knot slopes). Lumetri's curves and Glow's Arbitrary Map keep the monotone spline (not measured). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color2.rs | 76 +++++++++++++++++++++++++++++++++++- crates/gpu/src/effects.rs | 2 +- docs/effects-parity.md | 3 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index d78b0fb08..9547fd775 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -55,6 +55,17 @@ impl Curve { /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or /// fewer than two valid points. pub fn parse(s: &str) -> Option { + Self::parse_with(s, false) + } + + /// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline + /// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and + /// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does). + pub fn parse_natural(s: &str) -> Option { + Self::parse_with(s, true) + } + + fn parse_with(s: &str, natural: bool) -> Option { let mut pts: Vec<(f32, f32)> = s .split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) @@ -74,6 +85,12 @@ impl Curve { let ys: Vec = pts.iter().map(|p| p.1).collect(); let n = xs.len(); let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + if natural { + let ms = natural_slopes(&xs, &d); + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect(); + return Some(c); + } let mut ms = vec![0.0f32; n]; ms[0] = d[0]; ms[n - 1] = d[n - 2]; @@ -136,6 +153,40 @@ impl Curve { } } +/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with +/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly. +fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec { + let n = xs.len(); + if n < 3 { + return vec![d.first().copied().unwrap_or(0.0); n]; + } + let h: Vec = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect(); + let dd: Vec = d.iter().map(|&v| f64::from(v)).collect(); + // Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm. + let m = n - 2; + let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]); + for i in 0..m { + let k = i + 1; + a[i] = h[k - 1]; + b[i] = 2.0 * (h[k - 1] + h[k]); + c[i] = h[k]; + r[i] = 6.0 * (dd[k] - dd[k - 1]); + } + for i in 1..m { + let w = a[i] / b[i - 1]; + b[i] -= w * c[i - 1]; + r[i] -= w * r[i - 1]; + } + let mut sec = vec![0.0f64; n]; + for i in (0..m).rev() { + let next = if i + 1 < m { sec[i + 2] } else { 0.0 }; + sec[i + 1] = (r[i] - c[i] * next) / b[i]; + } + let mut ms: Vec = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect(); + ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32); + ms +} + fn ev(c: &Option, v: f32) -> f32 { c.as_ref().map(|c| c.eval(v)).unwrap_or(v) } @@ -145,7 +196,7 @@ fn ev(c: &Option, v: f32) -> f32 { /// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the /// master RGB curve first, then their own curve. fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id))); if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { return b; } @@ -1199,6 +1250,29 @@ mod tests { assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); } + /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) + /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. + #[test] + fn curves_follow_a_natural_spline_as_in_after_effects() { + let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); + let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); + let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); + let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; + let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; + for x in 0..10 { + let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; + let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; + assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); + assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); + } + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..33deb36a8 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- per-pixel fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id))); + let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id))); if parsed.iter().all(Option::is_none) { return Some(b); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 4e9a2ddb7..099e783ec 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -72,6 +72,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | | 2026-10-19 | minor | Fill without a mask: the layer becomes the fill colour at its alpha × Opacity (was a mix with the original), measured against After Effects 2026 (CPU and GPU) | +| 2026-10-19 | minor | Curves: natural cubic spline through the points, clamped to 0..1 (was a monotone spline), measured against After Effects 2026 (CPU and GPU); Lumetri's curves and Glow's Arbitrary Map keep the monotone spline | | 2026-10-18 | minor | Tint uses Rec. 601 luminance weights, measured against After Effects 2026 renders of red, green, blue and grey solids (CPU and GPU) | | 2026-10-18 | minor | Exposure converts to linear light with a pure 2.2 power curve, as measured in After Effects (Offset 0.0005 / 0.002 / 0.02 / 0.1 on black: 8 / 15 / 43 / 90), not the piecewise sRGB curve; its sliders take After Effects' ranges (Exposure −100..100, slider −4..4; Offset −2..2, slider −0.5..0.5; Gamma Correction 0.1..10) | | 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending | From 8b715fc22736a9a2434ebf57dc6813e7861fab6a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:18:58 +0200 Subject: [PATCH 0190/1386] Masks: coarsen the feather grid instead of dropping its padding on huge layers Above MAX_GRID_PX the padded feather grid fell back to the unpadded buffer, so a feathered Add mask just outside a very large layer (e.g. 8192 x 8192, feather 40) covered nothing and the whole layer vanished (CodeRabbit on #1874). The grid is now coarsened by 2, 4, ... until it fits; radii and padding scale with it, so the feather of an edge outside the layer still fades into it. The doc comment on the Linear falloff no longer says it is unchanged: wide linear ramps now go through the coarse grid. Regression tests: a_grid_too_big_is_coarsened_and_keeps_its_padding, a_coarsened_grid_still_feathers_a_mask_outside_the_buffer. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/masks.rs | 132 +++++++++++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 21 deletions(-) diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index f0e0eeee0..e6ba0a6fb 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -76,9 +76,13 @@ const SMOOTH_BOX_RADIUS: f64 = 0.37; /// bounds the padded area, i.e. memory.) const FULL_RES_RADIUS: usize = 48; const COARSE_RADIUS: usize = 32; -/// The largest padded grid (pixels); a bigger one (only reachable with a huge buffer) falls back -/// to blurring within the buffer. +/// The largest padded grid (pixels). A grid that would be bigger (a huge buffer) is coarsened +/// (`grid`) until it fits, so the padding, and with it the feather of a mask edge outside the +/// buffer, is never dropped. const MAX_GRID_PX: usize = 1 << 26; +/// The coarsest factor `grid` tries before giving up on the padding (unreachable for any buffer +/// that fits in memory: 1024 x 1024 buffer pixels per grid pixel). +const MAX_COARSEN: usize = 1024; /// Padding for a variable-width feather, which is rasterised at the buffer's resolution. const MAX_VARIABLE_PAD: usize = 256; @@ -103,8 +107,10 @@ impl Axis { } let cap = |pad: usize| if full_res_only { pad.min(MAX_VARIABLE_PAD) } else { pad }; if linear { - // Linear falloff (not measured against After Effects; kept as it was): one box pass, - // a straight ramp `feather` pixels wide across the edge. + // Linear falloff (not measured against After Effects): one box pass, a straight ramp + // `feather` pixels wide across the edge, as before. Not quite unchanged: a ramp wider + // than about `4 x FULL_RES_RADIUS` pixels is now blurred on the coarse grid and + // resampled bilinearly (a smooth ramp, so the difference is a rounding of the ramp). let r = (f / 2.0).round().max(1.0) as usize; let step = if full_res_only || r <= 2 * FULL_RES_RADIUS { 1 } else { r.div_ceil(2 * COARSE_RADIUS) }; let rg = ((r as f64 / step as f64).round() as usize).max(1); @@ -136,6 +142,22 @@ impl Axis { self.radii.iter().any(|&r| r > 0) } + /// The same blur on a grid `k` times coarser: radii and padding scaled down (a blurring + /// axis keeps at least one grid pixel of radius), the padding still covering the support. + fn coarser(self, k: usize) -> Axis { + if k <= 1 { + return self; + } + let blurs = self.blurs(); + let mut radii = self.radii.map(|r| (r as f64 / k as f64).round() as usize); + if blurs && radii.iter().all(|&r| r == 0) { + radii[0] = 1; + } + let support = radii.iter().fold(0usize, |a, &r| a.saturating_add(r)).saturating_add(1); + let pad = if self.pad == 0 { 0 } else { self.pad.div_ceil(k).max(support) }; + Axis { step: self.step.saturating_mul(k), pad, radii } + } + /// Grid pixels for `n` buffer pixels. fn len(&self, n: u32) -> Option { (n as usize).div_ceil(self.step).checked_add(self.pad.checked_mul(2)?) @@ -169,36 +191,59 @@ fn crop(grid: &Mask, ax: Axis, ay: Axis, w: u32, h: u32) -> Mask { fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { let Value::Path(sp) = ctx.group_value(layer, g, "path")? else { return None }; - let path = effectcraft_path::to_kurbo(&sp); - let (w, h) = (buf.img.width, buf.img.height); let m = Mat3::translate(vec2(buf.offset[0], buf.offset[1])) * Mat3::scale(vec2(buf.scale, buf.scale)); let exp = ctx.f(layer, g, "expansion", 0.0); let linear = matches!(g.kind, GroupKind::Mask { feather_falloff: FeatherFalloff::Linear, .. }); + let feather = ctx.v2(layer, g, "feather", [0.0; 2]); + let feather = [feather[0] * buf.scale, feather[1] * buf.scale]; + Some(feathered(&sp, &m, (buf.img.width, buf.img.height), exp, feather, linear, MAX_GRID_PX)) +} + +/// The padded grid for a `w x h` buffer: the axes as given, or coarsened together (by 2, 4, …) +/// until the grid has at most `max_px` pixels. `None` without a blur (or, unreachably, when even +/// the coarsest grid is too big). +fn grid(w: u32, h: u32, ax: Axis, ay: Axis, max_px: usize) -> Option<(u32, u32, Axis, Axis)> { + if !(ax.blurs() || ay.blurs()) { + return None; + } + let mut k = 1usize; + while k <= MAX_COARSEN { + let (cx, cy) = (ax.coarser(k), ay.coarser(k)); + if let (Some(gw), Some(gh)) = (cx.len(w), cy.len(h)) + && gw.checked_mul(gh).is_some_and(|n| n <= max_px) + && let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) + { + return Some((gw, gh, cx, cy)); + } + k = k.saturating_mul(2); + } + None +} + +/// Coverage of the mask path `sp` (layer -> buffer `m`) on a `w x h` buffer, expanded by `exp` +/// and feathered by `feather` buffer pixels per axis, on a padded grid of at most `max_px` pixels. +fn feathered(sp: &effectcraft_keyframe::ShapePath, m: &Mat3, (w, h): (u32, u32), exp: f64, feather: [f64; 2], linear: bool, max_px: usize) -> Mask { + let path = effectcraft_path::to_kurbo(sp); // Variable-width feather (Mask Feather tool points): expansion and the per-point feather // are applied together from the signed distance to the path. let variable = !sp.feather.is_empty(); - let feather = ctx.v2(layer, g, "feather", [0.0; 2]); // The feather blurs the mask's own edge, also where the path lies outside the layer (After // Effects: a feathered mask just below a layer still fades into it). So a feathered mask is // rasterised on a grid padded by the blur's reach, blurred there and cropped to the buffer. - // A variable feather scales its point radii by the mean scale: it stays at full resolution. - let (ax, ay) = (Axis::new(feather[0] * buf.scale, linear, variable), Axis::new(feather[1] * buf.scale, linear, variable)); + // A variable feather scales its point radii by the mean scale: it stays at full resolution + // (unless the grid has to be coarsened to fit `max_px`). + let (ax, ay) = (Axis::new(feather[0], linear, variable), Axis::new(feather[1], linear, variable)); let blurred = ax.blurs() || ay.blurs(); - let grid = match (ax.len(w), ay.len(h)) { - (Some(gw), Some(gh)) if blurred && gw.checked_mul(gh).is_some_and(|n| n <= MAX_GRID_PX) => { - let (Ok(gw), Ok(gh)) = (u32::try_from(gw), u32::try_from(gh)) else { return None }; - Some((gw, gh, ax, ay)) - } - _ => None, - }; - // Without the grid: blurred within the buffer at its resolution (the behaviour before the padding). + let grid = grid(w, h, ax, ay, max_px); + // Without the grid (no blur, or, unreachably, a grid too big even at the coarsest step): + // blurred within the buffer at its resolution. let unpadded = |f: f64| Axis { pad: 0, ..Axis::new(f, linear, true) }; - let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0] * buf.scale), unpadded(feather[1] * buf.scale))); + let (gw, gh, gx, gy) = grid.unwrap_or_else(|| (w, h, unpadded(feather[0]), unpadded(feather[1]))); // Buffer pixels -> grid pixels. let to_grid = Mat3::scale(vec2(1.0 / gx.step as f64, 1.0 / gy.step as f64)) * Mat3::translate(vec2((gx.pad * gx.step) as f64, (gy.pad * gy.step) as f64)); - let mg = to_grid * m; + let mg = to_grid * *m; let mut cov = if variable { - effectcraft_path::feather::variable_feather_coverage(&sp, &mg, gw, gh, exp, linear) + effectcraft_path::feather::variable_feather_coverage(sp, &mg, gw, gh, exp, linear) } else if exp.abs() > 0.01 { // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a @@ -221,7 +266,7 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, if grid.is_some() { cov = crop(&cov, gx, gy, w, h); } - Some(cov) + cov } /// The layer's enabled masks at the context time, flattened to polylines in layer space (for @@ -299,3 +344,48 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo buf.img.mul_mask(&acc); true } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_keyframe::ShapePath; + + /// CodeRabbit on #1874: an 8192 x 8192 layer with a 40 px Smooth feather needs a padded + /// grid larger than `MAX_GRID_PX`; it used to fall back to the unpadded buffer, so an Add mask + /// just outside the layer covered nothing and the whole layer vanished. The grid is now + /// coarsened instead and keeps a pad that covers the blur. + #[test] + fn a_grid_too_big_is_coarsened_and_keeps_its_padding() { + let a = Axis::new(40.0, false, false); + assert!(a.len(8192).zip(a.len(8192)).is_some_and(|(x, y)| x * y > MAX_GRID_PX), "the full-resolution grid is over the limit"); + let (gw, gh, gx, gy) = grid(8192, 8192, a, a, MAX_GRID_PX).expect("a padded grid"); + assert!((gw as usize) * (gh as usize) <= MAX_GRID_PX); + for g in [gx, gy] { + assert!(g.step > 1 && g.pad > 0 && g.blurs(), "{g:?}"); + // The pad covers the blur's support (the three boxes' radii) in buffer pixels. + assert!(g.pad * g.step >= g.radii.iter().sum::() * g.step, "{g:?}"); + } + // A grid that fits is not touched. + assert_eq!(grid(200, 100, a, a, MAX_GRID_PX).map(|g| g.2), Some(a)); + } + + /// The same failure at a testable size: a 200 x 100 buffer, a 40 px feather and a grid limit + /// just below the full-resolution padded grid. The Add mask lies just right of the buffer + /// (x 210..260); its feather still fades into the layer, close to the full-resolution result. + #[test] + fn a_coarsened_grid_still_feathers_a_mask_outside_the_buffer() { + let sp = ShapePath::rect([235.0, 50.0], 50.0, 200.0); + let m = Mat3::IDENTITY; + let full = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, MAX_GRID_PX); + let a = Axis::new(40.0, false, false); + let limit = a.len(200).zip(a.len(100)).map_or(0, |(x, y)| x * y) - 1; + let coarse = feathered(&sp, &m, (200, 100), 0.0, [40.0, 40.0], false, limit); + let at = |c: &Mask, x: usize, y: usize| c.data.get(y * 200 + x).copied().unwrap_or(-1.0); + assert!(at(&full, 199, 50) > 0.25, "full resolution: {}", at(&full, 199, 50)); + assert!(at(&coarse, 199, 50) > 0.2, "the soft fade survives the coarse grid: {}", at(&coarse, 199, 50)); + for x in [150, 170, 180, 190, 199] { + assert!((at(&coarse, x, 50) - at(&full, x, 50)).abs() < 0.05, "x {x}: coarse {} full {}", at(&coarse, x, 50), at(&full, x, 50)); + } + assert!(at(&coarse, 100, 50) < 0.01, "far from the mask stays clear"); + } +} From e244d642350787aaa7028470855368165012d029 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:25:20 +0200 Subject: [PATCH 0191/1386] Reject non-finite numbers in CSS colour parsing Found while testing crates/svg/src/color.rs:182-185 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/color.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/color.rs b/crates/svg/src/color.rs index ba4183e75..9ef5faa0a 100644 --- a/crates/svg/src/color.rs +++ b/crates/svg/src/color.rs @@ -179,7 +179,11 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> { return None; } let comp = |t: &str| -> Option { - Some(if let Some(p) = t.strip_suffix('%') { p.trim().parse::().ok()? / 100.0 } else { t.parse::().ok()? / 255.0 }) + Some(if let Some(p) = t.strip_suffix('%') { + p.trim().parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().ok().filter(|v| v.is_finite())? / 255.0 + }) }; let alpha = a.get(3).map_or(Some(1.0), |t| alpha(t))?; return Some([comp(a[0])?.clamp(0.0, 1.0), comp(a[1])?.clamp(0.0, 1.0), comp(a[2])?.clamp(0.0, 1.0), alpha]); @@ -189,9 +193,9 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> { if a.len() < 3 { return None; } - let h = a[0].trim_end_matches("deg").parse::().ok()?.rem_euclid(360.0) / 360.0; - let s = a[1].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; - let l = a[2].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; + let h = a[0].trim_end_matches("deg").parse::().ok().filter(|v| v.is_finite())?.rem_euclid(360.0) / 360.0; + let s = a[1].trim_end_matches('%').parse::().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0; + let l = a[2].trim_end_matches('%').parse::().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0; let al = a.get(3).map_or(Some(1.0), |t| alpha(t))?; let [r, g, b] = hsl(h, s, l); return Some([r, g, b, al]); @@ -208,7 +212,14 @@ fn split_args(s: &str) -> Vec<&str> { } fn alpha(t: &str) -> Option { - Some(if let Some(p) = t.strip_suffix('%') { p.parse::().ok()? / 100.0 } else { t.parse::().ok()? }.clamp(0.0, 1.0)) + Some( + if let Some(p) = t.strip_suffix('%') { + p.parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().ok().filter(|v| v.is_finite())? + } + .clamp(0.0, 1.0), + ) } /// CSS HSL → RGB (CSS Color 3 §4.2.4). @@ -239,6 +250,22 @@ fn hsl(h: f64, s: f64, l: f64) -> [f64; 3] { mod tests { use super::parse_color; + #[test] + fn non_finite_rejected() { + for s in [ + "rgb(nan,0,0)", + "rgb(inf,0,0)", + "rgba(0,0,0,nan)", + "rgba(0,0,0,inf)", + "hsl(nan,100%,50%)", + "hsl(120,nan%,50%)", + "hsl(120,100%,inf%)", + "hsl(120,100%,50%,nan)", + ] { + assert_eq!(parse_color(s), None, "{s}"); + } + } + #[test] fn colors() { assert_eq!(parse_color("#f00"), Some([1.0, 0.0, 0.0, 1.0])); From e559a7ce9ac93a35c655c65276ddd01684746610 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:38:41 +0200 Subject: [PATCH 0192/1386] Review: tie the colour kernels' literals to the CPU constants; visible GPU skip; B&C wording WGSL cannot read Rust constants, so the Rec. 601 luma (Tint, Tritone, Luma Key / Extract), Black & White's tint weights and Exposure's 2.2 power stay literals in the kernels; they now name their Rust source (effects::LUMA_601, BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, newly exported) and the test shader_constants_match_the_cpu (no GPU needed) fails if a literal drifts. gpu_tint_defaults_match_after_effects_greys says "skipped: no GPU adapter" when it skips (EFFECTCRAFT_REQUIRE_GPU=1 still makes it fail). docs/effects-parity.md: Brightness & Contrast is close to, not the same as, After Effects (up to 6.2/255), and the summary row lists what is measured. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color_fx.rs | 13 +++++++++++-- crates/effects/src/lib.rs | 6 +++--- crates/gpu/src/shaders/fx_color.wgsl | 4 +++- crates/gpu/src/shaders/fx_key.wgsl | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 6 ++++-- crates/gpu/src/tests.rs | 29 +++++++++++++++++++++++++++- docs/effects-parity.md | 6 +++--- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 8e451f4eb..402d0ebe2 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -22,9 +22,13 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { /// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green /// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. pub(crate) fn luma_601(c: [f32; 3]) -> f32 { - 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] + LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] } +/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); @@ -337,6 +341,7 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { /// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion /// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 /// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it. pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; /// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless @@ -379,8 +384,12 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid /// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), /// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { - let lum = |c: [f32; 3]| 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]; + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; if !gray.is_finite() || gray <= 0.0 { return [0.0; 3]; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 13a4130d0..a2497fcfb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -70,11 +70,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; pub use distort::transform_shutter; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index daac9fb5a..f616a2d9d 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -102,6 +102,7 @@ fn fxc_sc_weights(c0: vec3) -> array { // Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the // gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu). fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { if (!(gray > 0.0)) { return vec3(0.0); @@ -191,7 +192,8 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - // Rec. 601 weights, as effects::color_fx::tritone. + // Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by + // the test shader_constants_match_the_cpu). let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index 37146c329..bee93ea65 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,7 +62,8 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. -// Luma Key / Extract luminance: Rec. 601 (effects::color_fx::luma_601). +// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test +// shader_constants_match_the_cpu). fn fxk_key_luma(c: vec3) -> f32 { return dot(c, vec3(0.299, 0.587, 0.114)); } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 2d00ccfa9..6960da85c 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -605,7 +605,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - // Rec. 601 weights, as After Effects (effects crate luma_601). + // Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test + // shader_constants_match_the_cpu). let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; @@ -726,7 +727,8 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { var o = vec3(0.0); for (var i = 0; i < 3; i++) { // Per-channel gain, offset, gamma; negative values are mirrored. The - // linear-light conversion is a pure 2.2 power (effects crate exposure). + // linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA + // (checked by the test shader_constants_match_the_cpu). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 11dc81a93..a481ff736 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -855,7 +855,11 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { /// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). #[test] fn gpu_tint_defaults_match_after_effects_greys() { - let Some(g) = gpu() else { return }; + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter"); + return; + }; for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { let mut s = Scene::new(BitDepth::Bpc32); s.p.settings.working_space = Some(ColorSpace::Srgb); @@ -870,6 +874,29 @@ fn gpu_tint_defaults_match_after_effects_greys() { } } +/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this +/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise` +/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power. +#[test] +fn shader_constants_match_the_cpu() { + use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601}; + let kernels = include_str!("shaders/kernels.wgsl"); + let color = include_str!("shaders/fx_color.wgsl"); + let key = include_str!("shaders/fx_key.wgsl"); + let [r, g, b] = LUMA_601; + let dot = format!("dot(c, vec3({r}, {g}, {b}))"); + assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}"); + assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}"); + assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}"); + let [r, g, b] = BW_TINT_LUMA; + assert!(color.contains(&format!("let w = vec3({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}"); + let gamma = EXPOSURE_LINEAR_GAMMA; + assert!( + kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")), + "Exposure's power in kernels.wgsl is not {gamma}" + ); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 099e783ec..6defcba8e 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | Only Tint is checked against After Effects renders (four solids, within 1/255). `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **partly measured** | Some colour effects are checked against After Effects 2026 renders of our own test projects: Tint, Tritone, Black & White, Exposure, Levels, Curves, Luma Key, Extract, 4-Color Gradient and Fill within 1–3/255, Brightness & Contrast up to 6.2/255 (Color Correction, Generate and Keying rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Audio | 13 | 0 | CPU by design (applied in the mixdown) | | Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | | Channel | 13 | 13 | — | -| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) within about 6/255 of After Effects 2026 ramps (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | +| Color Correction | 40 | 40 | Tint, Tritone and Black & White (sliders and Tint) match After Effects 2026 renders (Rec. 601 luminance); Levels (Individual Controls) matches After Effects 2026 in 32 bpc (channel controls before RGB, "Off for 32 bpc Color" unclipped; plain Levels' channel order not measured); Brightness & Contrast (non-legacy) close to After Effects 2026 ramps, not identical (up to 6.2/255 off; the test allows 7/255) (negative settings invert the positive curves); Curves interpolates a natural cubic spline through its points, as After Effects 2026 (exact on its grey steps); Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed | | Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed | | Expression Controls | 9 | 9 | — | | Generate | 26 | 25 | 4-Color Gradient's Blend matches After Effects 2026 (weights 1 / (d² + Blend²)); Fill's Opacity is the filled layer's opacity, as After Effects 2026 renders it (with a Fill Mask it still mixes with the original, not measured); Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed | @@ -67,7 +67,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | -| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026), CPU and GPU | +| 2026-10-19 | minor | Brightness & Contrast: negative Brightness / Contrast are the inverse curves of the positive ones (Brightness −40: 20/255 → 6/255 off After Effects 2026; still up to 6.2/255 off, not the same pixels), CPU and GPU | | 2026-10-19 | minor | Luma Key: Key Out Brighter / Darker cut hard at Threshold, measured against After Effects 2026 renders of a ramp (CPU and GPU) | | 2026-10-19 | minor | Luma Key and Extract: Rec. 601 luminance (was Rec. 709), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | 4-Color Gradient: each point weighs 1 / (d² + Blend²), measured against After Effects 2026 at Blend 10 / 50 / 100 / 300 (CPU and GPU) | From 83c76f266fc6f1a2c445814a41e961ad85f6d624 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:49:00 +0200 Subject: [PATCH 0193/1386] Review: Glow's WGSL literals tied to GLOW_GAIN / GLOW_KNEE; parity summary consistent kernels.wgsl repeats Glow's bright-pass gain 8.0 and knee 0.0611 as literals; they now name effects::GLOW_GAIN / GLOW_KNEE (exported) and the test glow_constants_match_the_cpu (no GPU needed) fails if they drift. docs/effects-parity.md: the summary row no longer says no effect is compared with After Effects while the Blur & Sharpen row reports Radial Blur's comparison (CodeRabbit on #1873), and the Radial Blur changelog row says CPU only (it has no GPU path). Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 5 ++++- crates/effects/src/misc.rs | 3 ++- crates/gpu/src/shaders/kernels.wgsl | 4 +++- crates/gpu/src/tests.rs | 10 ++++++++++ docs/effects-parity.md | 4 ++-- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 71adf2bae..561a41cbe 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,7 +85,10 @@ use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, venetian_origin}; +pub use misc::{ + GLOW_GAIN, GLOW_KNEE, INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation, glow_sigma, radial_wipe_sigma, radial_wipe_wedge, venetian_cover, + venetian_origin, +}; pub use sim::particle_state; pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index fa92c998e..e39f311de 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -137,7 +137,8 @@ pub fn glow_bright_channel(v: f32, thr: f32) -> f32 { (GLOW_GAIN * (v - thr + GLOW_KNEE)).max(0.0) } -/// [`glow_bright_channel`]'s gain and threshold offset. +/// [`glow_bright_channel`]'s gain and threshold offset. `kernels.wgsl` repeats both as literals; +/// the GPU test `glow_constants_match_the_cpu` checks that they are these. pub const GLOW_GAIN: f32 = 8.0; pub const GLOW_KNEE: f32 = 0.0611; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index ca28e4fc7..a855ca38d 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -394,7 +394,9 @@ fn glow_bright(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y != 0u) { textureStore(out, p, vec4(px.xyz * k, ga)); } else { - // Original colours, per channel (effects::misc::glow_bright_channel). + // Original colours, per channel (effects::misc::glow_bright_channel): gain 8.0 is + // effects::GLOW_GAIN, 0.0611 is effects::GLOW_KNEE (checked by the test + // glow_constants_match_the_cpu). let c = max(8.0 * (px.xyz / a - vec3(thr - 0.0611)), vec3(0.0)); textureStore(out, p, vec4(c * a, ga)); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..e7a68e7bc 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -851,6 +851,16 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// WGSL cannot read the CPU's constants, so Glow's bright pass repeats them as literals; this +/// ties them to `effects::GLOW_GAIN` / `GLOW_KNEE` (runs without a GPU). +#[test] +fn glow_constants_match_the_cpu() { + use effectcraft_effects::{GLOW_GAIN, GLOW_KNEE}; + let kernels = include_str!("shaders/kernels.wgsl"); + let want = format!("max({GLOW_GAIN:?} * (px.xyz / a - vec3(thr - {GLOW_KNEE:?})), vec3(0.0))"); + assert!(kernels.contains(&want), "Glow's bright pass in kernels.wgsl is not `{want}`"); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 92a9775c1..2f97c8e56 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -15,7 +15,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | -| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| **Rendered output matches After Effects** | **partly measured** | Radial Blur, Glow, Venetian Blinds, Radial Wipe, Fractal / Turbulent Noise and Transform are checked against After Effects 2026 renders of our own test projects (rows below); every other effect is unmeasured. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | | Animation presets | **54** original presets (8 text-animator, 23 Transitions, 23 Backgrounds) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`, `crates/engine/presets/backgrounds.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels (26.5) present in Effect Controls only, not in the Timeline; the rebuilt Effect Controls (26.5) missing | Release notes | @@ -66,7 +66,7 @@ the GPU is performance, not parity. |---|---|---| | 2026-10-19 | minor | Glow (original colours): bright pass per channel, 8 × (value − threshold + 0.061), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Glow (colour channels): the glow covers the layer's blurred alpha, blur σ = 0.384 × radius, measured against After Effects 2026 renders (CPU and GPU) | -| 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU and GPU) | +| 2026-10-19 | minor | Radial Blur repeats the edge instead of sampling transparency outside the layer, measured against After Effects 2026 renders (CPU; Radial Blur has no GPU path) | | 2026-10-19 | minor | Venetian Blinds: stripes measured from the layer centre, both band edges Gaussian (σ = Feather / 2), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU | From 0a78eb28708513cf01dbae9cff17151ac2a3c4a0 Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 12:56:33 -0400 Subject: [PATCH 0194/1386] fix(script): bound and cancel socket writes (#1248) --- Cargo.lock | 11 +++ crates/script/Cargo.toml | 3 + crates/script/src/socket.rs | 135 ++++++++++++++++++++++++++++++++---- 3 files changed, 134 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..e2daffcef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "socket2", "web-time", ] @@ -4815,6 +4816,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" diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index 10c635ebc..6d03d1bdc 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -27,3 +27,6 @@ boa_engine = { workspace = true, features = ["js"] } [dev-dependencies] effectcraft-expr = { workspace = true } effectcraft-psd = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +socket2 = "0.6" diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 4030332de..3dee94deb 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -51,6 +51,7 @@ mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -65,9 +66,10 @@ mod imp { /// socket blocked on a slow peer doesn't hold up the others (#1248). struct Entry { sock: Mutex, - /// A second handle on a stream, so `close` can shut it down while another call is - /// blocked reading or writing it. + /// A second handle on a stream, so `close` can wake a blocked read. wake: Option, + /// Writes poll this flag because shutdown alone may not interrupt a Windows send. + closed: AtomicBool, } struct Table { @@ -85,7 +87,7 @@ mod imp { Sock::Stream { s, .. } => s.try_clone().ok(), Sock::Listener(_) => None, }; - let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); + let entry = Arc::new(Entry { sock: Mutex::new(s), wake, closed: AtomicBool::new(false) }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; @@ -101,11 +103,23 @@ mod imp { /// Forget socket `id`; a call blocked on it fails at once. fn close(id: u32) { let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); - if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { - let _ = w.shutdown(Shutdown::Both); + if let Some(entry) = entry { + entry.closed.store(true, Ordering::Release); + if let Some(w) = &entry.wake { + let _ = w.shutdown(Shutdown::Both); + } } } + /// A test-only handle for establishing backpressure before exercising the public operations. + #[cfg(test)] + pub(super) fn test_stream(id: u32) -> TcpStream { + let entry = get(id).unwrap(); + let sock = entry.sock.lock().unwrap(); + let Sock::Stream { s, .. } = &*sock else { panic!("expected a stream") }; + s.try_clone().unwrap() + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -150,6 +164,36 @@ mod imp { Ok(()) } + /// Write with one deadline and an explicit close signal. On Windows, shutdown alone does + /// not reliably interrupt a blocking send, so never leave a send blocked inside the OS. + /// The caller holds this socket's lock; restore blocking mode before another operation. + fn write(s: &mut TcpStream, data: &[u8], limit: Duration, closed: &AtomicBool) -> std::io::Result<()> { + s.set_nonblocking(true)?; + let end = Instant::now() + limit; + let result = (|| { + let mut pending = data; + while !pending.is_empty() { + if closed.load(Ordering::Acquire) { + return Err(std::io::Error::new(ErrorKind::ConnectionAborted, "the socket was closed")); + } + let left = end.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::from(ErrorKind::TimedOut)); + } + match s.write(pending) { + Ok(0) => return Err(std::io::Error::from(ErrorKind::WriteZero)), + Ok(n) => pending = pending.get(n..).ok_or_else(|| std::io::Error::from(ErrorKind::InvalidData))?, + Err(e) if e.kind() == ErrorKind::WouldBlock => std::thread::sleep(left.min(Duration::from_millis(5))), + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(()) + })(); + let restored = s.set_nonblocking(false); + result.and(restored) + } + pub(super) fn run(op: &str, id: u32, p: &J) -> J { let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); match op { @@ -265,11 +309,8 @@ mod imp { // A peer that stops reading fills the buffers: give up after `timeout` // instead of blocking the script for good (#1248). let limit = timeout(p).max(Duration::from_millis(1)); - if let Err(e) = s.set_write_timeout(Some(limit)) { - return err(format!("cannot set the write timeout: {e}")); - } let data = p.get("data").and_then(J::as_str).unwrap_or(""); - match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { + match write(s, &encode(data, utf8), limit, &entry.closed) { Ok(()) => json!({"ok": true}), Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", @@ -289,6 +330,7 @@ mod imp { #[cfg(test)] #[cfg(not(target_arch = "wasm32"))] mod tests { + use std::io::{ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -301,6 +343,7 @@ mod tests { /// `host:port` and the accepted stream's keeper (drop it to let the connection go). fn stalled_peer() -> (String, mpsc::Sender<()>) { let l = TcpListener::bind("127.0.0.1:0").unwrap(); + socket2::SockRef::from(&l).set_recv_buffer_size(4096).unwrap(); let host = l.local_addr().unwrap().to_string(); let (stop, wait) = mpsc::channel::<()>(); std::thread::spawn(move || { @@ -315,9 +358,26 @@ mod tests { r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 } - /// More than the loopback buffers hold, so a peer that doesn't read stops the write. - fn big() -> String { - "x".repeat(64 * 1024 * 1024) + /// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls. + /// In particular, Windows may accept that entire write with its default send buffering. + fn backpressure(id: u32) { + let mut stream = super::imp::test_stream(id); + socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap(); + stream.set_nonblocking(true).unwrap(); + let start = Instant::now(); + let chunk = [b'x'; 4096]; + let mut sent = 0; + loop { + assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure"); + match stream.write(&chunk) { + Ok(0) => panic!("peer closed while establishing backpressure"), + Ok(n) => sent += n, + Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => panic!("establishing backpressure: {e}"), + } + } + stream.set_nonblocking(false).unwrap(); } /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. @@ -334,11 +394,12 @@ mod tests { // #1248: it used to block for good. let (host, _keep) = stalled_peer(); let id = open(&host); - let data = big(); + backpressure(id); + let data = "x".repeat(64 * 1024); let t0 = Instant::now(); let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); - assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + assert!(t0.elapsed() >= Duration::from_millis(500) && t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); run("close", id, &J::Null); } @@ -348,12 +409,14 @@ mod tests { let (host_b, _keep_b) = stalled_peer(); let (a, b) = (open(&host_a), open(&host_b)); // A write on A that would wait a minute. - let data = big(); + backpressure(a); + let data = "x".repeat(64 * 1024); let (tx, blocked) = mpsc::channel(); std::thread::spawn(move || { let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); }); std::thread::sleep(Duration::from_millis(500)); + assert!(matches!(blocked.try_recv(), Err(mpsc::TryRecvError::Empty)), "A must still be writing before close"); // Socket B answers meanwhile (the table isn't locked by A's write). let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); assert_eq!(r["value"], true, "{r}"); @@ -363,4 +426,46 @@ mod tests { assert!(r.get("error").is_some(), "{r}"); run("close", b, &J::Null); } + + /// Both outgoing and accepted sockets deliver complete data, then return to blocking reads. + #[test] + fn writes_deliver_all_data_and_restore_blocking_reads() { + for accepted in [false, true] { + let (id, mut peer) = if accepted { + let listener = run("listen", 0, &json!({"port": 0})); + let listener_id = listener["id"].as_u64().unwrap() as u32; + let peer = TcpStream::connect(("127.0.0.1", listener["port"].as_u64().unwrap() as u16)).unwrap(); + let start = Instant::now(); + let id = loop { + if let Some(id) = run("poll", listener_id, &J::Null)["id"].as_u64() { + break id as u32; + } + assert!(start.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(1)); + }; + run("close", listener_id, &J::Null); + (id, peer) + } else { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let id = open(&listener.local_addr().unwrap().to_string()); + (id, listener.accept().unwrap().0) + }; + socket2::SockRef::from(&super::imp::test_stream(id)).set_send_buffer_size(4096).unwrap(); + peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let reader = std::thread::spawn(move || { + let mut data = vec![0; 256 * 1024]; + peer.read_exact(&mut data).unwrap(); + assert!(data.iter().all(|b| *b == b'x')); + // A read on a socket accidentally left nonblocking would return before this. + std::thread::sleep(Duration::from_millis(100)); + peer.write_all(b"ack").unwrap(); + }); + let r = within(Duration::from_secs(10), move || run("write", id, &json!({"data": "x".repeat(256 * 1024), "timeout": 5}))).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + let r = run("read", id, &json!({"count": 3, "timeout": 2})); + assert_eq!(r["data"], "ack", "{r}"); + run("close", id, &J::Null); + reader.join().unwrap(); + } + } } From 6442465bf1adaab9bac97a7207b46ffe47f78ad7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 12:59:32 -0400 Subject: [PATCH 0195/1386] CI: build the desktop app and CLI on Windows with MSVC, GNU, and GNU with bundled LLD (refs #1886) --- .github/workflows/windows-toolchains.yml | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/windows-toolchains.yml diff --git a/.github/workflows/windows-toolchains.yml b/.github/workflows/windows-toolchains.yml new file mode 100644 index 000000000..9c51e8acc --- /dev/null +++ b/.github/workflows/windows-toolchains.yml @@ -0,0 +1,70 @@ +# Windows builds with the toolchains contributors use, not just the MSVC one releases use: build +# the desktop app and the CLI with MSVC, with GNU (mingw-w64 from MSYS2, the setup CONTRIBUTING.md +# describes), and with GNU linked by Rust's bundled LLD plus mingw-w64's import libraries (#1886), +# then run each binary with `--version`. Both packages build in one `cargo build`, the way people +# build them, so features unify and the CLI links everything the desktop app pulls in. +name: Windows toolchains + +# Nightly on main (a dependency update is what brings in a new system library), and on PRs that +# change this workflow or the lockfile. +on: + schedule: + - cron: "41 5 * * *" + pull_request: + paths: + - .github/workflows/windows-toolchains.yml + - Cargo.lock + workflow_dispatch: + +concurrency: + group: windows-toolchains-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.name }} + runs-on: windows-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - { name: msvc, target: x86_64-pc-windows-msvc } + - { name: gnu, target: x86_64-pc-windows-gnu } + - { name: gnu-rust-lld, target: x86_64-pc-windows-gnu, rust-lld: true } + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Rust (stable-${{ matrix.target }}) + run: | + rustup toolchain install stable-${{ matrix.target }} --profile minimal --no-self-update + rustup default stable-${{ matrix.target }} + rustc -vV + - name: mingw-w64 from MSYS2 + if: matrix.target == 'x86_64-pc-windows-gnu' + run: /c/msys64/usr/bin/pacman -Sy --noconfirm --needed mingw-w64-x86_64-gcc + - name: Put it first on PATH + if: matrix.target == 'x86_64-pc-windows-gnu' && !matrix.rust-lld + run: | + echo 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH" + - name: Link with Rust's bundled LLD and mingw-w64's import libraries + if: matrix.rust-lld + run: | + echo 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=rust-lld' >> "$GITHUB_ENV" + echo 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS=-C link-self-contained=yes -L native=C:/msys64/mingw64/lib' >> "$GITHUB_ENV" + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: ${{ matrix.name }} + - name: Build the desktop app and the CLI + run: | + gcc --version | head -1 || true + cargo build --locked --target ${{ matrix.target }} -p effectcraft -p effectcraft-cli + - name: Run them + run: | + target/${{ matrix.target }}/debug/effectcraft.exe --version + target/${{ matrix.target }}/debug/effectcraft-cli.exe --version From 1e1e3d830cde01d2b5801980811e7df7cb887a76 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 12:59:50 -0400 Subject: [PATCH 0196/1386] Docs: building on Windows with MSVC, GNU, or GNU with bundled LLD (refs #1886) --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa28c9f0..42f23f771 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,37 @@ cargo test --workspace cargo xtask ci # what every commit must pass ``` +### Windows + +The MSVC toolchain, rustup's default on Windows, is what the releases use: with the Visual Studio +C++ build tools installed, the commands above work as they are. + +The GNU toolchain (`x86_64-pc-windows-gnu`) needs a current mingw-w64 GCC and binutils first on +`PATH`. They provide the linker and the Windows import libraries, such as `libshlwapi.a` for +opening links from the app. With [MSYS2](https://www.msys2.org/): + +```powershell +C:\msys64\usr\bin\pacman.exe -S --needed mingw-w64-x86_64-gcc +$env:PATH = "C:\msys64\mingw64\bin;$env:PATH" +cargo +stable-x86_64-pc-windows-gnu run -p effectcraft -- --demo +``` + +An older MinGW earlier on `PATH` can break the build: CodeBlocks' GCC 8.1 fails to link +`boa_engine` ([#1882](https://github.com/storytold/effectcraft/issues/1882)). + +Rust's bundled LLD and self-contained libraries don't include every import library EffectCraft +needs, so on their own they stop with `unable to find library -lshlwapi` +([#1886](https://github.com/storytold/effectcraft/issues/1886)). To link that way, add mingw-w64's +libraries to the search path in your own Cargo config (`%USERPROFILE%\.cargo\config.toml`): + +```toml +[target.x86_64-pc-windows-gnu] +linker = "rust-lld" +rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/msys64/mingw64/lib"] +``` + +The `Windows toolchains` workflow builds the app and the CLI each of these three ways every night. + ## Ground rules - **Clean room.** Work from After Effects' public documentation and its observable behaviour only. From f0af0f736af111508bdc657a3378dd0579603aa9 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 13:01:43 -0400 Subject: [PATCH 0197/1386] CI: write the MSYS2 PATH entry with printf, for shellcheck (refs #1886) --- .github/workflows/windows-toolchains.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-toolchains.yml b/.github/workflows/windows-toolchains.yml index 9c51e8acc..3d8b37ea8 100644 --- a/.github/workflows/windows-toolchains.yml +++ b/.github/workflows/windows-toolchains.yml @@ -51,7 +51,7 @@ jobs: - name: Put it first on PATH if: matrix.target == 'x86_64-pc-windows-gnu' && !matrix.rust-lld run: | - echo 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH" + printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH" - name: Link with Rust's bundled LLD and mingw-w64's import libraries if: matrix.rust-lld run: | From daedbd5ca1f0e727a54a16f7df2889067b4aebed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:21 +0200 Subject: [PATCH 0198/1386] Review: short "d" falls back to the font ascent; tracking unit comment; Lottie hunk out - first_ascent: a "d" shorter than 0.3 x the font ascent (a symbol or icon font) no longer puts box text's first baseline almost at the box top; the font ascent is used, as before the "d" rule (CodeRabbit on #1875). Test: a_short_d_falls_back_to_the_font_ascent. - CharXf::track_before / track_after are pixels, not 1/1000 em (CodeRabbit). - The Lottie export of style-run tracking is back to what it was: the pixel conversion was not checked against a player, so it is left for a separate, verified change. - The Subtract clamp in Mode::combine is intended for every animator property: the selectors combine into one selection per character for the whole animator, as in After Effects; the read-back observed it through Tracking (now said in the code). - Per-character 3D rotation notes that #1876 adds Mat4::rotation_xyz for the same order. Co-Authored-By: Claude Opus 5.5 --- crates/lottie/src/text.rs | 4 ++-- crates/render/src/text.rs | 6 +++++- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++++++++- crates/text/src/selectors.rs | 5 +++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/text.rs b/crates/lottie/src/text.rs index e9de3d328..125835c2b 100644 --- a/crates/lottie/src/text.rs +++ b/crates/lottie/src/text.rs @@ -227,8 +227,8 @@ fn style_run_animators(ex: &mut Ex, d: &TextDoc, label: &str) -> Vec { props.insert("s".into(), k(json!([p, p, 100.0]))); } if (s.tracking - base.tracking).abs() > 1e-9 { - // An animator's tracking is in pixels; the Character panel's in 1/1000 em. - props.insert("t".into(), k(json!((s.tracking - base.tracking) / 1000.0 * s.size))); + // Lottie tracking is in 1/1000 em like the Character panel's. + props.insert("t".into(), k(json!(s.tracking - base.tracking))); } if (s.baseline_shift - base.baseline_shift).abs() > 1e-9 { props.insert("p".into(), k(json!([0.0, -(s.baseline_shift - base.baseline_shift), 0.0]))); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c16419af8..47f5953fa 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (1/1000 em) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -752,6 +753,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) * Mat4::rotate_z(x.rotation) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3ce08b84f..646a0377a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -577,8 +577,20 @@ fn first_ascent(face: FaceId, px: f32) -> Option { let units = crate::outline_units(face, gid)?; let top = kurbo::Shape::bounding_box(&*units).y1 as f32; let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { let h = em * px; - (h.is_finite() && h > 0.0 && em < 4.0).then_some(h) + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) } struct ParaLine { @@ -1284,6 +1296,29 @@ mod tests { } } + /// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the + /// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. + #[test] + fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } + } + #[test] fn newlines_make_paragraphs_and_leading_adds() { let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index 0306b7e09..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -74,6 +74,11 @@ impl Mode { // Subtracting more than is selected leaves nothing, not a negative selection (After // Effects 2026: a Subtract selector over unselected characters leaves their // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, From 2fd7d933ee6b6f5c6d89c24168a3daa3d6ece248 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:41 +0200 Subject: [PATCH 0199/1386] Review: fidelity notes no longer claim the Lottie tracking export change Co-Authored-By: Claude Opus 5.5 --- docs/fidelity/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index db579e257..b2992749d 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -98,8 +98,8 @@ characters at 80 px and at 40 px, half before and half after each), and a Subtra over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text -preset now animates 40 → 0 px (was 400 → 0 thousandths of an em); Lottie export writes a style run's -tracking as pixels. Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, `modes_combine`. ## Per-character 3D rotation order From 82e383deb27abe3bf994d36ccaf5b30c29836d43 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 13:26:19 -0400 Subject: [PATCH 0200/1386] CI: give bundled LLD only libshlwapi.a, not all of mingw-w64's libraries (refs #1886) --- .github/workflows/windows-toolchains.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-toolchains.yml b/.github/workflows/windows-toolchains.yml index 3d8b37ea8..b6765455e 100644 --- a/.github/workflows/windows-toolchains.yml +++ b/.github/workflows/windows-toolchains.yml @@ -1,6 +1,6 @@ # Windows builds with the toolchains contributors use, not just the MSVC one releases use: build # the desktop app and the CLI with MSVC, with GNU (mingw-w64 from MSYS2, the setup CONTRIBUTING.md -# describes), and with GNU linked by Rust's bundled LLD plus mingw-w64's import libraries (#1886), +# describes), and with GNU linked by Rust's bundled LLD plus mingw-w64's `libshlwapi.a` (#1886), # then run each binary with `--version`. Both packages build in one `cargo build`, the way people # build them, so features unify and the CLI links everything the desktop app pulls in. name: Windows toolchains @@ -52,11 +52,15 @@ jobs: if: matrix.target == 'x86_64-pc-windows-gnu' && !matrix.rust-lld run: | printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH" - - name: Link with Rust's bundled LLD and mingw-w64's import libraries + # Only libshlwapi.a, in a folder of its own: with all of mingw-w64's lib folder on the search + # path, its C runtime mixes with Rust's bundled one (undefined `DllEntryPoint`). + - name: Link with Rust's bundled LLD and mingw-w64's shlwapi import library if: matrix.rust-lld run: | + mkdir -p /c/gnu-import-libs + cp /c/msys64/mingw64/lib/libshlwapi.a /c/gnu-import-libs/ echo 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=rust-lld' >> "$GITHUB_ENV" - echo 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS=-C link-self-contained=yes -L native=C:/msys64/mingw64/lib' >> "$GITHUB_ENV" + echo 'CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS=-C link-self-contained=yes -L native=C:/gnu-import-libs' >> "$GITHUB_ENV" - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: ${{ matrix.name }} From d5a7ab0a136a7f20e4dd1ecc755996b4b985d309 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 13:26:19 -0400 Subject: [PATCH 0201/1386] Docs: bundled LLD takes libshlwapi.a in a folder of its own (refs #1886) --- CONTRIBUTING.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42f23f771..5f36bc5cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,13 +33,20 @@ An older MinGW earlier on `PATH` can break the build: CodeBlocks' GCC 8.1 fails Rust's bundled LLD and self-contained libraries don't include every import library EffectCraft needs, so on their own they stop with `unable to find library -lshlwapi` -([#1886](https://github.com/storytold/effectcraft/issues/1886)). To link that way, add mingw-w64's -libraries to the search path in your own Cargo config (`%USERPROFILE%\.cargo\config.toml`): +([#1886](https://github.com/storytold/effectcraft/issues/1886)). To link that way, copy +mingw-w64's `libshlwapi.a` into a folder of its own and add that folder to the search path in your +own Cargo config (`%USERPROFILE%\.cargo\config.toml`). Don't add all of mingw-w64's `lib` folder: +its C runtime clashes with Rust's bundled one (`undefined symbol: DllEntryPoint`). + +```powershell +New-Item -ItemType Directory -Force C:\gnu-import-libs +Copy-Item C:\msys64\mingw64\lib\libshlwapi.a C:\gnu-import-libs\ +``` ```toml [target.x86_64-pc-windows-gnu] linker = "rust-lld" -rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/msys64/mingw64/lib"] +rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/gnu-import-libs"] ``` The `Windows toolchains` workflow builds the app and the CLI each of these three ways every night. From c628dc7d96982955e3cf437cf9b2715d4d4f0c93 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 21:42:50 +0400 Subject: [PATCH 0202/1386] docs: the Georgian panel count is at this revision; the other counts stay upstream's dated recount Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- docs/localization-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f074c5e55..9d53be6d3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -24,7 +24,8 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3; Georgian has every row the Chinese catalogs have. + counted at 470ea7a3. Georgian has every row the Chinese catalogs have at this revision (695, as + both Chinese catalogs now have), plus 191 for panels converted to the catalog. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts From 81548edff996104a48b22b1d3c69a64d6e532cda Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:36 +0200 Subject: [PATCH 0203/1386] Resolve SVG font-size percentages against the parent font size Found while testing crates/svg/src/lib.rs:624 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 6 +++++- crates/svg/src/tests.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 24cc584e0..82ab5943f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -621,7 +621,11 @@ impl<'a, 'i> Parser<'a, 'i> { if let Some(c) = d.get("color").and_then(|v| parse_color(v)) { st.color = c; } - if let Some(v) = d.get("font-size").and_then(|v| self.length(v, 'o', st.font_size)) { + let font_size = d.get("font-size").and_then(|v| match v.trim().strip_suffix('%') { + Some(p) => p.trim().parse::().ok().map(|p| p / 100.0 * st.font_size), + None => self.length(v, 'o', st.font_size), + }); + if let Some(v) = font_size.filter(|v| v.is_finite()) { st.font_size = v; } if let Some(p) = d.get("fill").and_then(|v| self.paint_ref(v)) { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 93db715c1..5c5838ebd 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -277,3 +277,18 @@ fn a_symbol_used_a_thousand_times_still_imports() { let doc = parse(svg.as_bytes()).unwrap(); assert_eq!(depth_and_shapes(&doc.root).1, 3000); } + +#[test] +fn font_size_percentage_resolves_against_the_parent_font_size() { + for (body, expected) in [ + (r#""#, 32.0), + (r#""#, 32.0), + (r#""#, 64.0), + ] { + let svg = format!(r#"{body}"#); + let d = parse(svg.as_bytes()).unwrap(); + let shapes = d.flatten(); + assert_eq!(shapes.len(), 1); + assert!(matches!(shapes[0].2.geom, Geom::Rect { w, .. } if w == expected), "{body}: {:?}", shapes[0].2.geom); + } +} From 35691f8d19f9cb37c7ddf94f91460a7d085a934a Mon Sep 17 00:00:00 2001 From: ded Date: Mon, 19 Oct 2026 20:11:14 +0300 Subject: [PATCH 0204/1386] G5: bench builds scenarios from an args file on Windows xtask bench passed the whole layers scenario on one command line, longer than Windows' 32 767-character limit (os error 206), so no scenario past demo ran. effectcraft-cli run takes --args-file F (one argument per line) and bench writes its commands there. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 12 ++++++++++-- apps/effectcraft-cli/tests/cli.rs | 23 +++++++++++++++++++++++ xtask/src/bench.rs | 15 +++++++++------ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c7a012c76..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] @@ -96,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] @@ -148,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -515,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; diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 1111db403..538887d9b 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -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"); diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs index b368d942f..6c89672aa 100644 --- a/xtask/src/bench.rs +++ b/xtask/src/bench.rs @@ -427,15 +427,18 @@ fn footage_cmds(seq_first: &Path, clip: &Path, still: &Path) -> Cmds { c } -/// Run `cmds` with `effectcraft-cli --empty run … --save-as out`. +/// Run `cmds` with `effectcraft-cli --empty run --args-file … --save-as out`: a file, because the +/// `layers` scenario is longer than a Windows command line (32 767 characters). fn build_project(bin: &Path, cmds: &Cmds, out: &Path) -> Result<(), String> { - let mut cmd = Command::new(bin); - cmd.arg("--empty").arg("run"); + let mut lines = String::new(); for (id, params) in cmds { - cmd.arg(id).arg(params.to_string()); + lines.push_str(&format!("{id}\n{params}\n")); } - cmd.arg("--save-as").arg(out); - cli(&mut cmd) + let args = out.with_extension("args"); + std::fs::write(&args, lines).map_err(|e| format!("{}: {e}", args.display()))?; + let r = cli(Command::new(bin).arg("--empty").arg("run").arg("--args-file").arg(&args).arg("--save-as").arg(out)); + let _ = std::fs::remove_file(&args); + r } /// Run a CLI command quietly; on failure, the tail of its output is the error. From eca2c3279352e0b6623db982c4631704be2ca8f3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:31:18 +0200 Subject: [PATCH 0205/1386] Fix hsv_to_rgb returning magenta for tiny negative hues Found while testing crates/color/src/lib.rs:143-158 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fae0ea90..5545f390d 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -141,7 +141,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 +256,12 @@ 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 srgb_roundtrip() { for i in 0..=100 { From a05d97a0e929c54fa8a3e1e461a11cfa9b8fd8b2 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 20:44:32 +0200 Subject: [PATCH 0206/1386] effects: speed up Match Grain's CPU analysis The sample-box search rescanned every candidate box and the grain statistics blurred the whole image three times per layer, although Match Grain only reads the 80 sample boxes (~6.5k pixels). On a 4K frame that analysis was ~90 % of the effect. - measure the statistics from small crops around each box (3 px halo, the Gaussian's support), deduplicating overlapping boxes so the sums match the previous masked version; - search the candidate box rows in parallel (the sort is a total order, so the chosen boxes are unchanged); - compute the "own" boxes and stats only when Compensate for Existing Noise is on, and clone the frame for Preview composition only in Preview mode; - run Plane::transpose/map_rows on the calling thread for small planes: the rayon dispatch dominated per-box crops (240 blurs of ~15x15). Release, Ryzen Threadripper 1920X, noisy source / flat target, defaults, best of 4 (1080p / 4K): - grain statistics: 151/463 ms -> 1.2/2.8 ms - sample boxes (source): 43/179 ms -> 16/71 ms - end to end: 266/1416 ms -> 99/465 ms (Compensate 50 %: 392/1830 -> 104/482) Tests: the boxed statistics match the masked ones (overlapping and corner boxes, alpha holes), plus the existing Match Grain/Remove Grain suites. --- crates/effects/src/noise.rs | 52 +++++----- crates/effects/src/noise2.rs | 186 ++++++++++++++++++++++++++++++----- crates/effects/src/util.rs | 18 ++++ 3 files changed, 206 insertions(+), 50 deletions(-) diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index d39fd6528..9d21e77a7 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -264,25 +264,30 @@ pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize } let low = Plane::luma(img); let step = (size / 2).max(1); - let mut cands: Vec<(f32, usize, usize)> = Vec::new(); - for y in (0..=h - size).step_by(step) { - for x in (0..=w - size).step_by(step) { - // Variance of the picture inside the box (the flattest boxes hold the least detail). - let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); - for yy in y..y + size { - for xx in x..x + size { - let v = low.data[yy * w + xx]; - s += v; - sq += v * v; - n += 1.0; - opaque &= img.data[yy * w + xx][3] > 0.5; + // Candidate rows in parallel; the sort below is a total order (variance, y, x), so the + // result does not depend on the collection order. + let rows: Vec = (0..=h - size).step_by(step).collect(); + let mut cands: Vec<(f32, usize, usize)> = rows + .par_iter() + .flat_map_iter(|&y| { + let data = low.data.as_slice(); + let alpha = img.data.as_slice(); + (0..=w - size).step_by(step).filter_map(move |x| { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= alpha[yy * w + xx][3] > 0.5; + } } - } - if opaque { - cands.push((sq / n - (s / n).powi(2), x, y)); - } - } - } + opaque.then(|| (sq / n - (s / n).powi(2), x, y)) + }) + }) + .collect(); cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() } @@ -479,16 +484,7 @@ pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(u /// The grain level (0.5..2) measured in the sample boxes of `img`. fn grain_level(img: &Image, boxes: &[(usize, usize, usize)]) -> f32 { - let mut m = vec![false; img.data.len()]; - let w = img.width as usize; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - let st = crate::noise2::grain_stats_masked(img, Some(&m)); + let st = crate::noise2::grain_stats_boxes(img, boxes); let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index 4c22f6d8f..cfdd4764e 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -75,6 +75,101 @@ pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainSta GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Grain statistics over sample `boxes` (in image pixels), measured from small crops around each +/// box instead of the whole image. Empty `boxes` measures every opaque pixel, like +/// [`grain_stats_masked`] without a mask. Overlapping boxes are deduplicated, so the sums match +/// the masked version. +pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> GrainStats { + if boxes.is_empty() { + return grain_stats_masked(img, None); + } + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so a 3 px halo is enough + // for the high-pass residual of every pixel inside a box. + const HALO: usize = 3; + let mut mask = vec![false; w * h]; + for &(x, y, s) in boxes { + for yy in y..y.saturating_add(s).min(h) { + for xx in x..x.saturating_add(s).min(w) { + mask[yy * w + xx] = true; + } + } + } + let mut counted = vec![false; w * h]; + let mut n = [0.0f64; 3]; + let mut sum = [0.0f64; 3]; + let mut sq = [0.0f64; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + let mut crop: Vec<([f32; 3], f32)> = Vec::new(); + let mut planes: Vec = Vec::new(); + for &(bx, by, bs) in boxes { + let x0 = bx.saturating_sub(HALO); + let y0 = by.saturating_sub(HALO); + let x1 = bx.saturating_add(bs).saturating_add(HALO).min(w); + let y1 = by.saturating_add(bs).saturating_add(HALO).min(h); + if x1 <= x0 || y1 <= y0 { + continue; + } + let (cw, ch) = (x1 - x0, y1 - y0); + crop.clear(); + crop.extend((y0..y1).flat_map(|y| (x0..x1).map(move |x| unpremul(img.data[y * w + x])))); + planes.clear(); + for k in 0..3 { + let pl = Plane { w: cw, h: ch, data: crop.iter().map(|s| s.0[k]).collect() }; + planes.push(gauss_plane(&pl, 1.5, 1.5)); + } + for ly in 0..bs { + let y = by + ly; + if y >= h { + break; + } + for lx in 0..bs { + let x = bx + lx; + if x >= w { + break; + } + let g = y * w + x; + if !mask[g] || counted[g] { + continue; + } + counted[g] = true; + let l = (y - y0) * cw + (x - x0); + let (_, a) = crop[l]; + if a <= 0.5 { + continue; + } + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + sum[k] += hp as f64; + sq[k] += (hp as f64) * (hp as f64); + n[k] += 1.0; + } + if x + 1 < w && mask[g + 1] && crop[l + 1].1 > 0.5 { + for k in 0..3 { + let hp = crop[l].0[k] - planes[k].data[l]; + let hn = crop[l + 1].0[k] - planes[k].data[l + 1]; + rho_num += (hp * hn) as f64; + rho_den += (hp * hp) as f64; + } + } + } + } + } + let mut std = [0.0f32; 3]; + for k in 0..3 { + if n[k] > 1.0 { + let mean = sum[k] / n[k]; + std[k] = ((sq[k] / n[k] - mean * mean).max(0.0)).sqrt() as f32; + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + /// Match Grain's Viewing Mode options. const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; @@ -86,34 +181,24 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); let src_boxes = sampling_boxes(ctx, &source, b.scale); - let own_boxes = sampling_boxes(ctx, &b.img, b.scale); let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); if view == 1 || view == 2 { // Noise Samples (on the source) / Compensation Samples (on this layer). - let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), sampling_boxes(ctx, &b.img, b.scale)) }; draw_boxes(&mut img, &boxes, box_col); b.img = img; return b; } - let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { - if boxes.is_empty() { - return None; - } - let w = img.width as usize; - let mut m = vec![false; img.data.len()]; - for &(x, y, s) in boxes { - for yy in y..y + s { - for xx in x..x + s { - m[yy * w + xx] = true; - } - } - } - Some(m) - }; - let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); + let src_stats = grain_stats_boxes(&source, &src_boxes); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; - let orig = b.img.clone(); + // The own-layer boxes only matter for the compensation term; skip them when it is off. + let own = if comp > 0.0 { + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + grain_stats_boxes(&b.img, &own_boxes) + } else { + GrainStats::default() + }; + let orig = (view == 0).then(|| b.img.clone()); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -147,8 +232,8 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); - if view == 0 { - crate::noise::preview_compose(ctx, &mut b, &orig); + if let Some(orig) = &orig { + crate::noise::preview_compose(ctx, &mut b, orig); } b } @@ -306,6 +391,63 @@ mod tests { img } + #[test] + fn boxed_grain_stats_match_the_masked_ones() { + // Noisy image with alpha holes; the boxes cover overlapping, edge-adjacent and + // bottom-right corner cases (clamped crops). + let mut img = noisy(64, 48, 0.2, 9); + for y in 0..img.height { + for x in 0..img.width { + if (x / 7 + y / 5) % 6 == 0 { + let mut p = img.get(x as i64, y as i64); + p[3] = 0.0; + img.set(x, y, p); + } + } + } + let boxes = [(3usize, 4usize, 9usize), (8, 6, 9), (40, 20, 12), (39, 21, 12), (55, 39, 9)]; + let w = img.width as usize; + let mut mask = vec![false; img.data.len()]; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + mask[yy * w + xx] = true; + } + } + } + let masked = grain_stats_masked(&img, Some(&mask)); + let boxed = grain_stats_boxes(&img, &boxes); + for k in 0..3 { + assert!((masked.std[k] - boxed.std[k]).abs() < 1e-5, "{k}: {masked:?} vs {boxed:?}"); + } + assert!((masked.size - boxed.size).abs() <= 0.05 * masked.size.max(1.0), "size: {masked:?} vs {boxed:?}"); + // A denser grid where every box overlaps its neighbours (mask dedup across boxes). + let img2 = noisy(96, 64, 0.15, 21); + let mut boxes2 = Vec::new(); + for gy in 0..5 { + for gx in 0..6 { + boxes2.push((gx * 11 + (gy % 3) * 4, gy * 10 + gx % 2, 12)); + } + } + let w2 = img2.width as usize; + let mut mask2 = vec![false; img2.data.len()]; + for &(x, y, s) in &boxes2 { + for yy in y..y + s { + for xx in x..x + s { + mask2[yy * w2 + xx] = true; + } + } + } + let masked2 = grain_stats_masked(&img2, Some(&mask2)); + let boxed2 = grain_stats_boxes(&img2, &boxes2); + for k in 0..3 { + assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-5, "{k}: {masked2:?} vs {boxed2:?}"); + } + assert!((masked2.size - boxed2.size).abs() <= 0.05 * masked2.size.max(1.0), "size: {masked2:?} vs {boxed2:?}"); + // No boxes measures the whole image, like no mask. + assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img)); + } + #[test] fn match_grain_transfers_grain_strength() { let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 1527e221f..f22a9885f 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -6,6 +6,10 @@ use effectcraft_color::{luminance, rgb_to_hsl}; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +/// Below this many pixels, `Plane::transpose` and `Plane::map_rows` run on the calling thread: +/// the rayon dispatch costs more than the work on small crops (the Match Grain sample boxes). +const PAR_MIN: usize = 4096; + /// A single-channel `f32` image (row-major). #[derive(Clone, Debug, Default, PartialEq)] pub struct Plane { @@ -62,6 +66,15 @@ impl Plane { pub fn transpose(&self) -> Plane { let (w, h) = (self.w, self.h); let mut out = Plane::new(h, w); + if w.saturating_mul(h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the copy itself. + for (x, col) in out.data.chunks_mut(h.max(1)).enumerate() { + for y in 0..h { + col[y] = self.data[y * w + x]; + } + } + return out; + } out.data.par_chunks_mut(h.max(1)).enumerate().for_each(|(x, col)| { for y in 0..h { col[y] = self.data[y * w + x]; @@ -75,6 +88,11 @@ impl Plane { if self.w == 0 { return out; } + if self.w.saturating_mul(self.h) < PAR_MIN { + // Small planes: the rayon dispatch costs more than the rows themselves. + out.data.chunks_mut(self.w).zip(self.data.chunks(self.w)).for_each(|(o, s)| f(s, o)); + return out; + } out.data.par_chunks_mut(self.w).zip(self.data.par_chunks(self.w)).for_each(|(o, s)| f(s, o)); out } From 45f54a4221bfa6987935e9c8d005a61cfbd840cd Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 21:22:03 +0200 Subject: [PATCH 0207/1386] effects: fix the grain-size drift from the crop halo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crop kept 3 px of halo, which makes the blur exact inside a box, but the lag-1 correlation also reads the right neighbour's high-pass residual — one column outside the box, still polluted by the crop edge when another box covers it (adjacent boxes are the norm with a step of size/2). The size estimate drifted by ~5e-5 in the parity test; a 4 px halo removes it. The parity test now holds std to 1e-6 and size to 1e-7 (was 5 % for size), and the boxed statistics still cost 1.8 / 3.1 ms at 1080p / 4K. --- crates/effects/src/noise2.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index cfdd4764e..48d840416 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -87,9 +87,10 @@ pub(crate) fn grain_stats_boxes(img: &Image, boxes: &[(usize, usize, usize)]) -> if w < 3 || h < 3 { return GrainStats::default(); } - // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so a 3 px halo is enough - // for the high-pass residual of every pixel inside a box. - const HALO: usize = 3; + // The Gaussian's three box passes at sigma 1.5 have radius 1 each, so 3 px of halo make the + // residual exact inside a box; one more column covers the right neighbour used by the lag-1 + // correlation. + const HALO: usize = 4; let mut mask = vec![false; w * h]; for &(x, y, s) in boxes { for yy in y..y.saturating_add(s).min(h) { @@ -418,9 +419,9 @@ mod tests { let masked = grain_stats_masked(&img, Some(&mask)); let boxed = grain_stats_boxes(&img, &boxes); for k in 0..3 { - assert!((masked.std[k] - boxed.std[k]).abs() < 1e-5, "{k}: {masked:?} vs {boxed:?}"); + assert!((masked.std[k] - boxed.std[k]).abs() < 1e-6, "{k}: {masked:?} vs {boxed:?}"); } - assert!((masked.size - boxed.size).abs() <= 0.05 * masked.size.max(1.0), "size: {masked:?} vs {boxed:?}"); + assert!((masked.size - boxed.size).abs() <= 1e-7, "size: {masked:?} vs {boxed:?}"); // A denser grid where every box overlaps its neighbours (mask dedup across boxes). let img2 = noisy(96, 64, 0.15, 21); let mut boxes2 = Vec::new(); @@ -441,9 +442,9 @@ mod tests { let masked2 = grain_stats_masked(&img2, Some(&mask2)); let boxed2 = grain_stats_boxes(&img2, &boxes2); for k in 0..3 { - assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-5, "{k}: {masked2:?} vs {boxed2:?}"); + assert!((masked2.std[k] - boxed2.std[k]).abs() < 1e-6, "{k}: {masked2:?} vs {boxed2:?}"); } - assert!((masked2.size - boxed2.size).abs() <= 0.05 * masked2.size.max(1.0), "size: {masked2:?} vs {boxed2:?}"); + assert!((masked2.size - boxed2.size).abs() <= 1e-7, "size: {masked2:?} vs {boxed2:?}"); // No boxes measures the whole image, like no mask. assert_eq!(grain_stats_boxes(&img, &[]), grain_stats(&img)); } From d6afa87641e3f57d96d7431ef6a2b19b53467df4 Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 15:30:35 -0400 Subject: [PATCH 0208/1386] fix(text): isolate CJK fallback language and cache entries (#1141) --- crates/text/src/fonts.rs | 172 ++++++++++++++++++++++++++++++------ crates/ui-egui/src/theme.rs | 18 ++-- 2 files changed, 156 insertions(+), 34 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index aeabf1872..9ace1f9cd 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -753,14 +753,48 @@ fn requested_cjk_locale() -> Option { /// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale /// environment (`LANG`, `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them). fn active_cjk_locale() -> String { - requested_cjk_locale().unwrap_or_else(|| { - ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] - .iter() - .filter_map(|k| std::env::var(k).ok()) - .map(|v| v.trim().to_ascii_lowercase()) - .find(|v| !v.is_empty() && v != "c" && v != "posix") - .unwrap_or_default() - }) + requested_cjk_locale().unwrap_or_else(environment_cjk_locale) +} + +/// Read the environment independently of another UI's process-wide language request. +fn environment_cjk_locale() -> String { + ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] + .iter() + .filter_map(|k| std::env::var(k).ok()) + .map(|v| v.trim().to_ascii_lowercase()) + .find(|v| !v.is_empty() && v != "c" && v != "posix") + .unwrap_or_default() +} + +/// The four distinct fallback policies, rather than unbounded user-supplied locale cache keys. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum CjkLocale { + Japanese, + Korean, + Simplified, + Traditional, +} + +impl CjkLocale { + fn from_tag(tag: &str) -> Self { + let tag = tag.trim().to_ascii_lowercase(); + if tag.starts_with("zh") { + if asks_traditional_chinese(&tag) { Self::Traditional } else { Self::Simplified } + } else if tag.starts_with("ko") { + Self::Korean + } else { + Self::Japanese + } + } + + fn tag(self) -> &'static str { + match self { + Self::Japanese => "ja", + Self::Korean => "ko", + Self::Simplified => "zh-hans", + Self::Traditional => "zh-hant", + } + } } /// The CJK language to try first for Han ideographs. Japanese when nothing says otherwise: the @@ -778,6 +812,7 @@ fn han_order_for(tag: &str) -> [FallbackScript; 3] { } /// The order for the locale in force ([`active_cjk_locale`]). +#[cfg(test)] fn han_order() -> [FallbackScript; 3] { han_order_for(&active_cjk_locale()) } @@ -796,15 +831,10 @@ fn chinese_families_for(tag: &str) -> &'static [&'static str] { if asks_traditional_chinese(tag) { TRADITIONAL_CHINESE_FAMILIES } else { SIMPLIFIED_CHINESE_FAMILIES } } -/// [`chinese_families_for`] for the locale in force ([`active_cjk_locale`]). -fn chinese_families() -> &'static [&'static str] { - chinese_families_for(&active_cjk_locale()) -} - -fn families_for(s: FallbackScript) -> &'static [&'static str] { +fn families_for(s: FallbackScript, locale: CjkLocale) -> &'static [&'static str] { match s { FallbackScript::Japanese => JAPANESE_FAMILIES, - FallbackScript::Han => chinese_families(), + FallbackScript::Han => chinese_families_for(locale.tag()), FallbackScript::Korean => KOREAN_FAMILIES, FallbackScript::Arabic => ARABIC_FAMILIES, FallbackScript::Hebrew => HEBREW_FAMILIES, @@ -881,9 +911,10 @@ fn fallback_cache() -> &'static RwLock &'static RwLock>>> { - static CACHE: OnceLock>>>> = OnceLock::new(); +/// The results of [`family_faces`], per script, preferred face and captured language policy. +/// A lookup that finishes after a language change cannot populate another language's entry. +fn family_faces_cache() -> &'static RwLock>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) } @@ -893,6 +924,23 @@ fn family_faces_cache() -> &'static RwLock FaceId { + fallback_with_locale(c, prefer, CjkLocale::from_tag(&active_cjk_locale())) +} + +/// Choose a fallback for an explicit interface language, independent of concurrent global +/// language changes. Empty, `c` and `posix` use the OS environment rather than the global request. +pub fn fallback_for_locale(c: char, prefer: FaceId, tag: &str) -> FaceId { + let tag = tag.trim(); + let locale = if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { + CjkLocale::from_tag(&environment_cjk_locale()) + } else { + CjkLocale::from_tag(tag) + }; + fallback_with_locale(c, prefer, locale) +} + +/// Keep a single language snapshot through the family-order lookup and cache insertion. +fn fallback_with_locale(c: char, prefer: FaceId, locale: CjkLocale) -> FaceId { let p = face(prefer); if p.has_char(c) || c.is_control() { return prefer; @@ -913,7 +961,7 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { if cfg!(not(target_arch = "wasm32")) && !system_scanned() { scan_system(); } - if let Some(&id) = family_faces(script, &p).iter().find(|&&id| face(id).has_char(c)) { + if let Some(&id) = family_faces(script, &p, locale).iter().find(|&&id| face(id).has_char(c)) { return id; } } @@ -942,23 +990,24 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { /// The script-aware part of [`fallback_for`]: the installed faces of the platform's usual families /// for `script`, in the order they are tried, each in the style of `prefer`. For CJK, the other CJK /// languages' families follow in the locale's order. -fn family_faces(script: FallbackScript, prefer: &Face) -> Arc> { - if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id)) { +fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc> { + if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id, locale)) { return faces.clone(); } let order: Vec = match script { FallbackScript::Other => vec![], - FallbackScript::Han => han_order().to_vec(), + FallbackScript::Han => han_order_for(locale.tag()).to_vec(), s if s.is_cjk() => { let mut v = vec![s]; - v.extend(han_order().iter().copied().filter(|o| *o != s)); + v.extend(han_order_for(locale.tag()).iter().copied().filter(|o| *o != s)); v } s => vec![s], }; let style = &prefer.info.style; - let faces: Arc> = Arc::new(order.into_iter().flat_map(families_for).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect()); - family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id), faces.clone()); + let faces: Arc> = + Arc::new(order.into_iter().flat_map(|s| families_for(s, locale)).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect()); + family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id, locale), faces.clone()); faces } @@ -975,6 +1024,77 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + /// Concurrent language requests must neither choose another context's font nor reuse its + /// cached family order. Synthetic fonts in a child process keep this independent of the host + /// font installation and of other tests that exercise the process-global database/locale. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn cjk_requests_keep_their_language_and_cache_entries() { + const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; + let mut expected = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + for (language, family) in families { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + // All four fonts cover the probe, so coverage cannot choose the right language. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + let id = push(&mut d, info_from(names, FaceData::Shared(data), "test")); + expected.push((language, id)); + } + } + let base = resolve("Inter", "Regular").face; + for &(_, id) in &expected { + assert!(face(id).has_char('页')); + } + // Force the original interleaving: Chinese starts, then Japanese replaces the global + // language before Chinese looks up its probe. The explicit request must still win. + set_cjk_locale("zh-hans"); + set_cjk_locale("ja"); + assert_eq!(fallback_for('页', base), expected[0].1); + assert_eq!(fallback_for_locale('页', base, "zh-hans"), expected[1].1); + // Do not clear the cache here: different policies must coexist in the same cache. + for _ in 0..3 { + for &(language, id) in &expected { + assert_eq!(fallback_for_locale('页', base, language), id, "{language}"); + } + } + let barrier = Arc::new(std::sync::Barrier::new(expected.len())); + let threads: Vec<_> = expected + .into_iter() + .map(|(language, id)| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + for _ in 0..128 { + set_cjk_locale(if language == "ja" { "zh-hans" } else { "ja" }); + assert_eq!(fallback_for_locale('页', base, language), id, "{language}"); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -1302,7 +1422,7 @@ mod tests { assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi"))); assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS"))); // The arm the fallback itself walks: Han hands over the list the locale's script picked. - assert_eq!(families_for(FallbackScript::Han), chinese_families_for(&active_cjk_locale())); + assert_eq!(families_for(FallbackScript::Han, CjkLocale::from_tag(&active_cjk_locale())), chinese_families_for(&active_cjk_locale())); // Han text is the only thing these lists serve; the Japanese order does not change. assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..fa1bfdcf1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -409,7 +409,9 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { - let id = fonts::fallback_for(probe, base); + // Another context may install a different language concurrently. Its global + // request must not choose the faces registered into this context. + let id = fonts::fallback_for_locale(probe, base, cjk_locale(language)); if seen.contains(&id) { continue; } @@ -443,9 +445,9 @@ fn cjk_locale(language: &str) -> &str { } /// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for -/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删, -/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would -/// draw the Chinese interface with boxes (#1141). +/// a hanzi used by its catalog. Some Japanese faces also cover 页 while lacking 选 or 删, so +/// coverage of this probe alone is not enough: the fallback must use the interface's own language +/// policy, even while another context is installing Japanese fonts (#1141). #[cfg(not(target_arch = "wasm32"))] fn han_probe(language: &str) -> char { if language == "zh-hans" { '页' } else { '文' } @@ -595,9 +597,9 @@ mod script_font_tests { } } - /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English - /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, - /// so it became the Chinese fallback too, without the simplified-only hanzi. + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. A Japanese face + /// can cover the Han probe while lacking other catalog characters. The context must keep its + /// own language policy even when another context installs Japanese fallbacks concurrently. #[test] fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() { let ctx = egui::Context::default(); @@ -609,7 +611,7 @@ mod script_font_tests { let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); let missing: String = ctx.fonts_mut(|fonts| { let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; - // Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). + // Representative hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). "选删层设导页" .chars() .filter(|ch| { From a3d5e55bc72b80a470d77b419b714c72ab5d4f28 Mon Sep 17 00:00:00 2001 From: Wes Campbell Date: Mon, 19 Oct 2026 14:33:59 -0500 Subject: [PATCH 0209/1386] EPS: Illustrator EPS files draw their artwork (#955) Illustrator EPS imported as an empty layer. Three gaps in the PostScript interpreter, each against the PostScript Language Reference: - An operator stored under another name (`/mo /moveto load def`, as Illustrator's prolog does for every path and paint operator) pushed the operator's name instead of running it, so nothing was drawn. - Device queries that return values (currentpagedevice, currentscreen, currentflat, currenthalftone, status, dictstack, ...) pushed nothing, shifting the stack under the procset's guards. They now push a plain device's defaults; setscreen pops its three operands. - `currentfile 0 (eod) /SubFileDecode filter` now skips to the end-of-data string, so an embedded XMP packet isn't run as PostScript. The regression test uses synthetic PostScript only. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/ps.rs | 102 ++++++++++++++++++++++++++++++++- crates/pdf/src/tests_import.rs | 20 +++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 45db6c2ea..984f7206b 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -467,6 +467,10 @@ impl Interp { if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), + // An operator stored under another name (`/mo /moveto load def`, as Illustrator's + // EPS prolog does for every path operator) runs that operator. It pushed the + // operator's name instead, so such files drew nothing (#955). + V::Name(op) => self.builtin(&op, src), other => { self.push(other); Ok(()) @@ -1203,6 +1207,96 @@ impl Interp { } } "currentglobal" | "currentpacking" | "currentshared" => self.push(V::Bool(false)), + // Operators that return values must push them, or the rest of the program reads a + // shifted stack: Illustrator's EPS prolog queries the device this way and then drew + // nothing (#955). Values are a plain device's defaults (PostScript Language + // Reference, 3rd ed., chapter 8). + "currentflat" => self.push(V::Num(1.0)), + "currentscreen" => { + self.push(V::Num(60.0)); + self.push(V::Num(45.0)); + self.push(V::Proc(Rc::new(vec![]))); + } + "currentblackgeneration" | "currentundercolorremoval" | "currenttransfer" => self.push(V::Proc(Rc::new(vec![]))), + "currentcolortransfer" => { + for _ in 0..4 { + self.push(V::Proc(Rc::new(vec![]))); + } + } + "currentpagedevice" | "currenthalftone" | "currentsystemparams" | "currentuserparams" | "currentdevparams" => { + if n == "currentdevparams" { + self.pop(); + } + self.push(V::Dict(new_dict())); + } + "setuserparams" | "setsystemparams" | "setcolortransfer" | "setblackgeneration" | "setundercolorremoval" => { + let k = if n == "setcolortransfer" { 4 } else { 1 }; + for _ in 0..k { + self.pop(); + } + } + // `file status` → false; `string status` → false (no such file). + "status" => { + self.pop(); + self.push(V::Bool(false)); + } + "dictstack" => { + self.pop(); + let ds: Vec = self.dicts.iter().map(|d| V::Dict(d.clone())).collect(); + self.push(array(ds)); + } + "maxlength" => { + let k = match self.pop() { + V::Dict(d) => d.borrow().len(), + _ => 0, + }; + self.push(V::Num(k.saturating_add(16) as f64)); + } + "internaldict" => { + self.pop(); + let d = self.dicts[0].clone(); + self.push(V::Dict(d)); + } + "undefineresource" => { + self.pop(); + self.pop(); + } + // `[ … /Name pdfmark`: a Distiller annotation; other interpreters discard it. + "pdfmark" => { + while let Some(v) = self.st.pop() { + if matches!(v, V::Mark) { + break; + } + } + } + // `source [params] /Name filter`. A SubFileDecode of the current file with a count of + // 0 reads to its end-of-data string: skip that data (an embedded XMP packet, which + // otherwise ran as PostScript). Other filters stand for the file they read. + "filter" => { + let name = self.pop().key(); + if name == "SubFileDecode" { + let eod = match self.pop() { + V::Str(s) => s.borrow().clone(), + _ => vec![], + }; + let count = self.popn(); + if let V::File = self.pop() + && count == 0.0 + && !eod.is_empty() + { + src.pos = match find_from(src.d, &eod, src.pos) { + Some(p) => p.saturating_add(eod.len()), + None => src.d.len(), + }; + } + } else { + if matches!(self.st.last(), Some(V::Dict(_))) { + self.pop(); + } + self.pop(); + } + self.push(V::File); + } "vmstatus" => { for _ in 0..3 { self.push(V::Num(0.0)); @@ -1236,9 +1330,15 @@ impl Interp { let a: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); self.gs.dash = (!a.is_empty() && a.iter().any(|v| *v > 0.0)).then_some((a, off)); } - "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "setscreen" | "settransfer" => { + "setflat" | "setstrokeadjust" | "setoverprint" | "setrenderingintent" | "setcolorrendering" | "sethalftone" | "settransfer" => { self.pop(); } + // frequency angle proc + "setscreen" => { + for _ in 0..3 { + self.pop(); + } + } "setgray" => { let g = self.popn().clamp(0.0, 1.0); self.gs.color = [g; 3]; diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index b6a95859c..7f46f6cf9 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -25,6 +25,26 @@ fn ink_x(img: &effectcraft_raster::Image) -> (i64, i64) { (x0, x1) } +/// #955: EPS files that name their operators (`/mo /moveto load def`, as Illustrator's do), +/// query the device and embed metadata read through a SubFileDecode filter draw their artwork. +/// They imported as an empty layer. +#[test] +fn eps_with_aliased_operators_device_queries_and_embedded_data_draws() { + let square = "1 0 0 setrgbcolor 50 25 mo 150 25 li 150 75 li 50 75 li cp f\n"; + let aliases = "/mo /moveto load def /li /lineto load def /cp /closepath load def /f /fill load def\n"; + let doc = eps(format!("{aliases}{square}").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "aliased operators draw the square"); + // Every operator that returns a value pushes it, so the stack stays aligned. + let queries = "mark currentflat currentscreen currentpagedevice currenthalftone currentblackgeneration \ + currentundercolorremoval currentcolortransfer currentsystemparams counttomark 13 eq /ok exch def cleartomark\n"; + let doc = eps(format!("{aliases}{queries}ok {{ {square} }} if\n").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "device queries leave 13 values"); + // Data up to the end-of-data string isn't run: its unbalanced brace would swallow the rest. + let data = "currentfile 0 (%END-OF-DATA) /SubFileDecode filter pop\n { ( not PostScript\n%END-OF-DATA\n"; + let doc = eps(format!("{aliases}{data}{square}").as_bytes()); + assert!(coverage(&render(&doc), 50, 25, 150, 75) > 4900.0, "drawing after the embedded data"); +} + #[test] fn eps_text_with_bundled_fonts_and_show_variants() { // Not embedded: drawn with the bundled stand-in, like the same text in a PDF. From e551cbe2cab89ea745677aac3fbb2e0066e84abd Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:26:34 -0400 Subject: [PATCH 0210/1386] test(render): use controlled costs for Auto backend checks --- crates/render/src/tests_auto.rs | 64 ++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index c34321cea..1e23254c0 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -9,31 +9,30 @@ use effectcraft_time::{FrameRate, Tick}; use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer}; -/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps -/// Auto timing history. -struct TimedAccel { - delay: std::time::Duration, +/// An accelerator that returns a representative pixel (or declines) and keeps Auto history. +struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, } -impl TimedAccel { - fn new(ms: u64, handle: bool) -> TimedAccel { - TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() } +impl MockAccel { + /// Construct a renderer stand-in without making assumptions about scheduling or speed. + fn new(handle: bool) -> MockAccel { + MockAccel { handle, calls: Default::default(), pick: Default::default() } } fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } } -impl Accelerator for TimedAccel { +impl Accelerator for MockAccel { fn name(&self) -> String { "timed".into() } + /// Count dispatched frames and return the same centre pixel as the CPU fixture. fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option { self.calls.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(self.delay); // The centre of the comp, as the CPU draws it (a red solid). self.handle.then(|| { let mut img = crate::Image::new(200, 100); @@ -64,7 +63,7 @@ fn project() -> (Project, ItemId) { } /// Render `n` frames with `backend`; how many the accelerator was asked for. -fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize { +fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize { let before = a.calls(); for _ in 0..n { let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() }); @@ -76,44 +75,53 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) a.calls() - before } +/// Drive the production selector with measured costs supplied by the test, not the OS clock. +fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize { + (0..n) + .filter(|_| { + let gpu = pick.choose(key); + pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms }); + gpu + }) + .count() +} + +/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history. #[test] fn auto_backend_moves_light_comps_to_the_cpu() { - // A light comp on a slow accelerator: after the warm-up (three frames on - // each side) Auto renders on the CPU, re-measuring the accelerator now and then. let (p, cid) = project(); - // Several times the CPU's frame time (unoptimised test builds are slow). - let t0 = std::time::Instant::now(); - for _ in 0..3 { - Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); - } - let cpu_ms = t0.elapsed().as_millis() as u64 / 3; - let slow = TimedAccel::new((cpu_ms * 5).max(20), true); + let slow = MockAccel::new(true); + let key = AutoKey::new(cid, 1.0, false); let n = 6 + 2 * AutoPick::REPROBE as usize; - let asked = frames(&p, cid, &slow, Backend::Auto, n); + let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n); assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames"); - let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap(); + let s = slow.pick.stats(key).unwrap(); assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}"); - // Backend::Gpu ignores the history; Software Only never asks. + // Check renderer wiring with known history before a real measurement can change it. + assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0); assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3); let mut sw = p.clone(); sw.settings.gpu_acceleration = false; assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0); } +/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes. #[test] fn auto_backend_keeps_fast_accelerators() { - // As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and - // re-probes. let (p, cid) = project(); - let fast = TimedAccel::new(0, true); - let asked = frames(&p, cid, &fast, Backend::Auto, 40); - assert!(asked >= 36, "asked {asked} of 40"); + for gpu_ms in [1.0, 5.0, 5.5] { + let fast = MockAccel::new(true); + let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40); + assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}"); + assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1); + } } +/// A declining accelerator is retried only periodically while the CPU supplies frames. #[test] fn auto_backend_falls_back_when_the_accelerator_declines() { let (p, cid) = project(); - let declining = TimedAccel::new(0, false); + let declining = MockAccel::new(false); let n = 2 * AutoPick::REPROBE as usize; let asked = frames(&p, cid, &declining, Backend::Auto, n); // Asked first, then only on the periodic re-probes. From 4a84b4da0814cd8021f9d4ac96628394320f631a Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:26:34 -0400 Subject: [PATCH 0211/1386] docs(script): explain socket dispatch and regression cases --- crates/script/src/socket.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 3dee94deb..15d6bdb40 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -194,6 +194,7 @@ mod imp { result.and(restored) } + /// Dispatch an authorized operation without holding the socket table during I/O. pub(super) fn run(op: &str, id: u32, p: &J) -> J { let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); match op { @@ -353,6 +354,7 @@ mod tests { (host, stop) } + /// Open a test connection through the production socket dispatcher. fn open(host: &str) -> u32 { let r = run("open", 0, &json!({"host": host, "timeout": 5})); r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 @@ -389,6 +391,7 @@ mod tests { rx.recv_timeout(limit).ok() } + /// A bounded write times out even when the peer never drains its receive buffer. #[test] fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { // #1248: it used to block for good. @@ -403,6 +406,7 @@ mod tests { run("close", id, &J::Null); } + /// Closing a stalled stream interrupts its writer without blocking another socket. #[test] fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { let (host_a, _keep_a) = stalled_peer(); From c4505d34de24749a7784bac6f88c964e24a9d6cd Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:47:51 -0400 Subject: [PATCH 0212/1386] test(render): document automatic backend fixtures --- crates/render/src/tests_auto.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index 1e23254c0..22f1a6215 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -21,6 +21,7 @@ impl MockAccel { fn new(handle: bool) -> MockAccel { MockAccel { handle, calls: Default::default(), pick: Default::default() } } + /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -51,6 +52,7 @@ impl Accelerator for MockAccel { } } +/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures. fn project() -> (Project, ItemId) { let mut p = Project::default(); p.settings.gpu_acceleration = true; @@ -128,6 +130,7 @@ fn auto_backend_falls_back_when_the_accelerator_declines() { assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames"); } +/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys. #[test] fn auto_pick_follows_changing_costs() { let pick = AutoPick::default(); From f667b6e11ec73c4bf3126a050de7b8c828b23e0b Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 19:19:20 -0400 Subject: [PATCH 0213/1386] fix(script): retire streams after failed mode transitions --- crates/script/src/socket.rs | 92 +++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 15d6bdb40..9d1f87989 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -68,7 +68,8 @@ mod imp { sock: Mutex, /// A second handle on a stream, so `close` can wake a blocked read. wake: Option, - /// Writes poll this flag because shutdown alone may not interrupt a Windows send. + /// Closed or unusable after a mode error. Writes poll it because Windows sends may not + /// be interrupted by shutdown alone; queued operations recheck it after locking. closed: AtomicBool, } @@ -164,16 +165,26 @@ mod imp { Ok(()) } + /// A failed mode transition leaves the stream unusable; reject queued operations and wake I/O. + fn change_mode(s: &TcpStream, entry: &Entry, nonblocking: bool, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> { + mode(s, nonblocking).map_err(|e| { + entry.closed.store(true, Ordering::Release); + let _ = s.shutdown(Shutdown::Both); + let action = if nonblocking { "enable nonblocking mode" } else { "restore blocking mode" }; + std::io::Error::other(format!("socket is unusable: cannot {action}: {e}")) + }) + } + /// Write with one deadline and an explicit close signal. On Windows, shutdown alone does /// not reliably interrupt a blocking send, so never leave a send blocked inside the OS. - /// The caller holds this socket's lock; restore blocking mode before another operation. - fn write(s: &mut TcpStream, data: &[u8], limit: Duration, closed: &AtomicBool) -> std::io::Result<()> { - s.set_nonblocking(true)?; + /// The caller holds this socket's lock; restore blocking mode or retire the connection. + fn write(s: &mut TcpStream, data: &[u8], limit: Duration, entry: &Entry, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> { + change_mode(s, entry, true, mode)?; let end = Instant::now() + limit; let result = (|| { let mut pending = data; while !pending.is_empty() { - if closed.load(Ordering::Acquire) { + if entry.closed.load(Ordering::Acquire) { return Err(std::io::Error::new(ErrorKind::ConnectionAborted, "the socket was closed")); } let left = end.saturating_duration_since(Instant::now()); @@ -190,12 +201,20 @@ mod imp { } Ok(()) })(); - let restored = s.set_nonblocking(false); - result.and(restored) + match (result, change_mode(s, entry, false, mode)) { + (Err(write), Err(restore)) => Err(std::io::Error::other(format!("{restore}; write also failed: {write}"))), + (result, Ok(())) => result, + (Ok(()), Err(restore)) => Err(restore), + } } /// Dispatch an authorized operation without holding the socket table during I/O. pub(super) fn run(op: &str, id: u32, p: &J) -> J { + run_with_mode(op, id, p, TcpStream::set_nonblocking) + } + + /// Dispatch with a mode setter so exceptional OS failures can be injected per operation. + pub(super) fn run_with_mode(op: &str, id: u32, p: &J, mode: impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> J { let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8")); match op { "open" => { @@ -243,6 +262,15 @@ mod imp { }; }; let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + // Recheck after locking: close or a failed mode transition may have retired it + // while this caller waited with an Arc obtained from the table. + if entry.closed.load(Ordering::Acquire) { + return match op { + "connected" => json!({"value": false}), + "eof" => json!({"value": true}), + _ => err("the socket is closed or unusable"), + }; + } match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { @@ -257,14 +285,18 @@ mod imp { ("connected", Sock::Stream { s, buf, eof }) => { if !*eof && buf.is_empty() { // Peek without blocking to notice a closed peer. - let _ = s.set_nonblocking(true); + if let Err(e) = change_mode(s, &entry, true, &mode) { + return err(e); + } let mut b = [0u8; 1]; match s.peek(&mut b) { Ok(0) => *eof = true, Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true, _ => {} } - let _ = s.set_nonblocking(false); + if let Err(e) = change_mode(s, &entry, false, &mode) { + return err(e); + } } json!({"value": !(*eof && buf.is_empty())}) } @@ -311,7 +343,7 @@ mod imp { // instead of blocking the script for good (#1248). let limit = timeout(p).max(Duration::from_millis(1)); let data = p.get("data").and_then(J::as_str).unwrap_or(""); - match write(s, &encode(data, utf8), limit, &entry.closed) { + match write(s, &encode(data, utf8), limit, &entry, &mode) { Ok(()) => json!({"ok": true}), Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", @@ -431,6 +463,46 @@ mod tests { run("close", b, &J::Null); } + /// Failed mode changes retire the stream; subsequent I/O fails instead of using it. + #[test] + fn mode_restoration_failure_retires_streams() { + for (op, fail_nonblocking) in [("write", false), ("connected", false), ("write", true), ("connected", true)] { + let (host, _keep) = stalled_peer(); + let id = open(&host); + let result = super::imp::run_with_mode(op, id, &json!({"data": "hello"}), |s, nonblocking| { + if nonblocking == fail_nonblocking { Err(std::io::Error::other("injected mode failure")) } else { s.set_nonblocking(nonblocking) } + }); + assert!(result["error"].as_str().is_some_and(|e| e.contains("injected mode failure")), "{op}: {result}"); + assert_eq!(run("connected", id, &J::Null)["value"], false, "{op}: broken socket remains connected"); + assert_eq!(run("eof", id, &J::Null)["value"], true); + for operation in ["read", "readln", "write"] { + let r = run(operation, id, &json!({"count": 1, "timeout": 0.01, "data": "x"})); + assert!(r.get("error").is_some(), "{operation} must reject the unusable stream: {r}"); + } + run("close", id, &J::Null); + run("close", id, &J::Null); + } + } + + /// A preceding write error cannot hide the failure that makes the stream unusable. + #[test] + fn restoration_failure_preserves_the_write_error_too() { + let (host, _keep) = stalled_peer(); + let id = open(&host); + let result = super::imp::run_with_mode("write", id, &json!({"data": "hello"}), |s, nonblocking| { + if nonblocking { + s.set_nonblocking(true)?; + s.shutdown(std::net::Shutdown::Write) + } else { + Err(std::io::Error::other("injected restore failure")) + } + }); + let error = result["error"].as_str().unwrap(); + assert!(error.contains("injected restore failure") && error.contains("write also failed"), "{error}"); + assert_eq!(run("connected", id, &J::Null)["value"], false); + run("close", id, &J::Null); + } + /// Both outgoing and accepted sockets deliver complete data, then return to blocking reads. #[test] fn writes_deliver_all_data_and_restore_blocking_reads() { From 67ccf44fbaa78492bfbd35cc603df4af390c129c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:38:45 +0200 Subject: [PATCH 0214/1386] Review: behavior preset test also covers redo Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_anim_presets.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests_anim_presets.rs index d23996e67..4d8b79e3f 100644 --- a/crates/engine/src/tests_anim_presets.rs +++ b/crates/engine/src/tests_anim_presets.rs @@ -140,15 +140,20 @@ fn every_behavior_preset_applies_and_undoes() { ["transform/position", "transform/scale", "transform/rotation", "transform/opacity"] .iter() .filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression())) - .count() + .copied() + .collect::>() }; for p in &list { let id = p["id"].as_str().unwrap(); let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap(); assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies"); assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects"); - assert_eq!(driven(&s), 1, "{id}: exactly one property is driven"); + let applied = driven(&s); + assert_eq!(applied.len(), 1, "{id}: exactly one property is driven"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(driven(&s).is_empty(), "{id}: undo removes the expression"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(driven(&s), applied, "{id}: redo restores the expression"); s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(driven(&s), 0, "{id}: undo removes the expression"); } } From 7c8ea2713df454118c9cf55c8a381bd07c6d7ccd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:41:23 +0200 Subject: [PATCH 0215/1386] Review: Delete in the render queue does nothing while a menu is open Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/render_queue.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 871c9dfeb..76000f796 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -870,6 +870,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && !rendering && let Some(id) = selected && app.dialog.is_none() + && !crate::widgets::any_menu_open(ui.ctx()) && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) // (A focused queue row is not typing; any other focused widget may be.) && (on_row || !ui.ctx().egui_wants_keyboard_input()) From 57326dc6a7d2fcd187cc45edba2dac4433894ac1 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 01:51:23 +0300 Subject: [PATCH 0216/1386] G5: stills at a reduced render size are resampled once and kept at that size (#1812) The media pool honoured the requested size only for movies; stills came back at full size and the renderer resampled them on every frame. A 21120 px wide plate at Quarter was re-filtered each frame, and four of them (1.85 GB as f32) did not fit the 1 GiB pool, so they were also decoded again and again. Stills are now resampled once with effectcraft_raster::resample (the same call the renderer made, so pixels are unchanged) and the pool keeps the reduced copy under the sized key. #1812 project at Quarter, CLI bench: 410 ms -> 33 ms a frame (26 ms with the layer cache); desktop playback 2% -> 60-99% of real time. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 79 +++++++++++++++++++++++------------ crates/media/tests/footage.rs | 26 ++++++++++++ 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9a8ec5c28..d3b6da0d4 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -708,15 +708,15 @@ impl Inner { } } - /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that - /// size directly (see [`FootageSource::frame_at_size`]). + /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]): + /// a movie's made at that size directly, a still's resampled once and kept at that size. fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); - if movie && let Some(size) = size { + if let Some(size) = size { loc.key.size = size; } let mut c = lock(&self.cache); @@ -773,34 +773,59 @@ impl Inner { #[cfg(target_arch = "wasm32")] fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + /// A still (image file or sequence frame) of the footage's own size, at `loc.key.size` when + /// one is set: resampled as the renderer would ([`FootageSource::frame_at_size`]), so the + /// pool keeps the reduced frame and a reduced-resolution render neither decodes nor resamples + /// a large still again for every frame (#1812: 21120-px plates at Quarter). + fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image { + let (w, h) = loc.key.size; + if w == 0 || h == 0 || (img.width, img.height) != (footage.width, footage.height) || (w, h) == (img.width, img.height) { + return img; + } + effectcraft_raster::resample(&img, w, h) + } + fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + match loc.media_t { + None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)), + Some(_) => self.decode_media(loc, footage), + } + } + + fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result { + let op = AlphaOp::of(footage, loc.float); + let path = &loc.key.path; + if &**path == PLACEHOLDER { + return Ok(colour_bars(footage.width, footage.height)); + } + let bytes = self.read(path)?; + if let Some(img) = crate::dpx::decode(path, &bytes)? { + return Ok(dynamic_to_image(&img, op)); + } + if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { + return Ok(img); + } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } + let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); + let img = match loaded { + Ok(img) => img, + // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + }; + Ok(dynamic_to_image(&img, op)) + } + + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; match loc.media_t { - None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), - None => { - let bytes = self.read(path)?; - if let Some(img) = crate::dpx::decode(path, &bytes)? { - return Ok(dynamic_to_image(&img, op)); - } - if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { - return Ok(img); - } - // (an OpenEXR compression the decoder lacks came out transparent or as a missing - // channel error) - if path.to_ascii_lowercase().ends_with(".exr") - && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) - { - return Err(MediaError::Decode(format!("{path}: {why}"))); - } - let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); - let img = match loaded { - Ok(img) => img, - // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, - }; - Ok(dynamic_to_image(&img, op)) - } + None => self.decode_still(loc, footage), Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..9e68e3f6b 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -580,6 +580,32 @@ fn sequence_gaps_show_placeholders() { let _ = std::fs::remove_dir_all(&dir); } +/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and +/// is kept at that size: later frames at that size neither decode nor resample it again (a +/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter). +#[test] +fn stills_at_reduced_sizes_are_resampled_once() { + let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let p = dir.join("plate.png"); + let px = |x: u32, y: u32| { + image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }]) + }; + image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png"); + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame"); + for (w, h) in [(306, 48), (153, 24), (77, 12)] { + let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample"); + let misses = pool.stats().misses; + let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame"); + assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size"); + assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn png_with_alpha() { let Some(p) = alpha_png() else { return }; From fd1250d224a5db35f7cb551667ce7bd6d7096145 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 03:13:48 +0300 Subject: [PATCH 0217/1386] G5: still footage layers are kept in the layer cache across times (#1812) Footage layers had no layer cache key, so a still made a new layer buffer every frame on every preview worker, and the GPU compositor uploaded it again each time (its upload cache is keyed by buffer). Stills (without a proxy) are now keyed like text and shape layers, without the source time; movies and sequences are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 18 ++++++++++++++---- crates/render/src/tests_footage_size.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..3fabe473a 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; -use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; +use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; @@ -481,7 +481,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { } /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context -/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment +/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed @@ -514,6 +514,13 @@ fn reads_other_times(layer: &Layer) -> bool { }) } +/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU +/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every +/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only. +fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { + ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -552,14 +559,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage => { + LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { let it = ctx.project.item(*item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. hash_debug(&mut h, &it.proxy); item.hash(&mut h); - ctx.source_time(layer).0.hash(&mut h); + // A still is the same at every time (unless a proxy, which may be a movie, stands in). + if f.kind != FootageKind::Still || it.proxy.is_some() { + ctx.source_time(layer).0.hash(&mut h); + } // Frame blending mixes neighbouring source frames. ctx.comp.enable_frame_blending.hash(&mut h); } diff --git a/crates/render/src/tests_footage_size.rs b/crates/render/src/tests_footage_size.rs index 88df726e9..065941d8a 100644 --- a/crates/render/src/tests_footage_size.rs +++ b/crates/render/src/tests_footage_size.rs @@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() { Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty()); } + +/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to +/// the GPU once, not every frame); a movie's is fetched again at every time. +#[test] +fn still_layers_are_cached_across_times_and_movies_are_not() { + for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] { + let (mut p, cid) = project(BitDepth::Bpc8, false); + let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") }; + if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) { + f.kind = kind; + } + let cache = crate::LayerCache::default(); + let src = Clip { sizes: Some(Mutex::default()) }; + let opts = RenderOpts { scale: 0.5, ..Default::default() }; + for t in [0.0, 0.25, 0.5] { + let t = Tick::from_seconds_f64(t); + let mut r = Renderer::new(&p, &src, opts); + r.cache = Some(&cache); + let frame = r.comp_frame(cid, t); + assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}"); + } + // (each uncached render above fetches once more) + assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}"); + } +} From ff20f8ec41291aff48dc32e4c3921ffaaf0c73bf Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 03:34:33 +0300 Subject: [PATCH 0218/1386] G5: a playing preview prefetches past the frames it already cached The playback prefetch only looked ahead*3 frames (96 with 16 workers) for uncached frames. After a loop or a restart from the start those were all cached, so the workers stood idle until the playhead came within that window of the end of the cached run, then the preview stalled. A playing preview now looks as far ahead as the RAM preview holds (and the range spans) for the next uncached frames, still queuing at most ahead at a time; eviction already spares the frames the playhead reaches first (#595). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 14 +++++++-- crates/ui-egui/tests/ui_preview_align.rs | 39 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..541081af1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1238,8 +1238,16 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); // Never further ahead than the RAM preview holds: those frames would evict the ones the - // preview shows first, or be evicted before it gets there (#595). - let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1); + // preview shows first, or be evicted before it gets there (#595). A playing preview + // looks that far for the next uncached frames: after a loop the frames just ahead are + // cached, and a shorter window left the workers idle until the playhead came near the + // end of the cached run. + let fit_ahead = self.frames_that_fit(&c, scale) - 1; + let window = match plan.filter(|_| self.playback.playing) { + Some(p) => fit_ahead.min((p.end - p.start) / p.step.max(1) + 1), + None => (ahead * 3).min(fit_ahead), + }; + let cached = self.frames.cached_frames(&series); for k in 0..window { if queued >= ahead as usize || !video { break; @@ -1257,7 +1265,7 @@ impl EffectcraftApp { f } }; - if !self.frames.is_cached(&FrameKey { frame: f, ..series }) { + if cached.binary_search(&f).is_err() { self.request_frame(cid, f, scale); queued += 1; } diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs index e9b8d6c44..2b2ba1b44 100644 --- a/crates/ui-egui/tests/ui_preview_align.rs +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -259,6 +259,45 @@ fn cache_frames_when_idle_fills_the_work_area() { assert_eq!(n, 240, "the work area is cached while idle"); } +/// A preview that plays into frames it already cached (after a loop) keeps rendering the next +/// uncached ones: the prefetch window used to end within the cached run, and the workers stood +/// idle until the playhead came near its end. +#[test] +fn playback_prefetches_past_the_cached_frames_ahead() { + let mut h = harness(); + let cid = h.state().session.active_comp_id().unwrap(); + let series = h.state().shown_series(cid); + let scale = series.scale as f64 / 1000.0; + // The first 120 frames (5 s) are cached: more than the widest old window (16 workers × 6). + for f in 0..120 { + h.state().request_frame(cid, f, scale); + } + for _ in 0..4000 { + if h.state().frames.cached_frames(&series).len() >= 120 && h.state().frames.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(h.state().frames.cached_frames(&series), (0..120).collect::>()); + h.state_mut().session.set_time(Tick::ZERO); + invoke(&mut h, "playback.toggle", json!({})); + h.run_steps(2); + assert!(h.state().playback.playing); + let cur = h.state().session.time(); + assert!(cur < Tick::from_seconds_f64(1.0), "{cur:?}"); + for _ in 0..4000 { + if h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120) { + break; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + if h.state().session.time() >= Tick::from_seconds_f64(1.0) { + break; + } + } + assert!(h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120), "frames past the cached run render while it plays the first second"); +} + #[test] fn cache_frames_when_idle_survives_an_inverted_work_area() { let mut h = harness(); From c33d5c20df766508bea366c4cbe3754c7999741d Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:01:07 +0300 Subject: [PATCH 0219/1386] G5: a still asked for at its own size is the unsized frame (#1812) Review: frame_at_size at a still's native size made a second cache key, so the pool decoded and kept a second full-size copy of a frame thumbnail() or frame() already had. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 3 ++- crates/media/tests/footage.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index d3b6da0d4..6bc4bd8dc 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -716,7 +716,8 @@ impl Inner { } let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); - if let Some(size) = size { + // A still asked for at its own size is the unsized frame (one decode, one entry). + if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { loc.key.size = size; } let mut c = lock(&self.cache); diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 9e68e3f6b..2e882d73d 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -595,6 +595,11 @@ fn stills_at_reduced_sizes_are_resampled_once() { let f = probe(&p).expect("probe"); let pool = MediaPool::new(); let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame"); + // At its own size it is the unsized frame: no second decode, no second full-size copy. + let misses = pool.stats().misses; + let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame"); + assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame"); + assert_eq!(pool.stats().misses, misses); for (w, h) in [(306, 48), (153, 24), (77, 12)] { let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame"); assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample"); From f41aa134a864dd0d02cfa95f4b8d0101eec7c163 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 12:09:16 +1100 Subject: [PATCH 0220/1386] Text Range Selector test: change Units through the Timeline popup (#1814) The Index-units test now opens the Units popup in the Timeline and picks Index, asserts the stored Units value, then reads the painted Start, End and Offset, so it also fails when the Units control doesn't update the session. --- crates/ui-egui/tests/ui_text_range_handles.rs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/ui_text_range_handles.rs index aab80100d..7ce301b37 100644 --- a/crates/ui-egui/tests/ui_text_range_handles.rs +++ b/crates/ui-egui/tests/ui_text_range_handles.rs @@ -209,8 +209,26 @@ fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String { .collect() } +/// The centre of the first painted text that reads `label` exactly (a popup's option). +fn text_at(h: &Harness<'_, EffectcraftApp>, label: &str) -> Option { + h.output().shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(t.visual_bounding_rect().center()), + _ => None, + }) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.event(Event::PointerMoved(p)); + h.step(); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + /// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as -/// counts, without the % sign they carry with Units = Percentage. +/// counts, without the % sign they carry with Units = Percentage. Units is changed the way a +/// user changes it: its Timeline popup, then the Index option. #[test] fn index_units_show_start_end_and_offset_without_a_percent_sign() { let (s, ids) = setup(); @@ -221,17 +239,29 @@ fn index_units_show_start_end_and_offset_without_a_percent_sign() { let selectors = anim.sub("selectors").unwrap(); let sel = selectors.groups().next().unwrap(); let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid); + let advanced = sel.sub("advanced").unwrap().uid; let mut h = harness(s); let tl = &mut h.state_mut().ui.timeline; tl.open_layers.insert(ids.layer); let animators = text.sub("animators").unwrap().uid; - tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid]); + tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid, advanced]); h.run_steps(3); for uid in [ids.start, ids.end, offset] { assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid)); } - h.state_mut().session.execute("prop.set", json!({"layer": ids.layer, "prop": units, "value": 1})).unwrap(); - h.run_steps(3); + // Units ▸ Index through the Timeline: open its popup, pick the option. + let units_value = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(l).unwrap().props.find(units).unwrap().value.clone(); + assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(0), "Percentage to start with"); + let field = h + .state() + .auto + .find(&format!("timeline.prop.{units}.value")) + .map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) + .expect("Units is shown"); + click_at(&mut h, field); + let index = text_at(&h, "Index").expect("the Units popup lists Index"); + click_at(&mut h, index); + assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(1), "the popup set Units to Index"); for uid in [ids.start, ids.end, offset] { let v = timeline_value(&h, uid); assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}"); From ea1f2fe9840c5741aaa4d4f6dab37eca8c579a64 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:21:14 +0300 Subject: [PATCH 0221/1386] G5: a still decoded larger than its nominal size is resampled like the renderer does (#1812) Review: still_at_size returned a smart object's pixels unresampled when they differed from the footage's nominal size. It now follows footage_buf: pixels larger than nominal are resampled to the requested size, smaller ones are left for the renderer to scale up. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 13 ++++++++----- crates/media/tests/footage.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 6bc4bd8dc..062bdac54 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -774,13 +774,16 @@ impl Inner { #[cfg(target_arch = "wasm32")] fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} - /// A still (image file or sequence frame) of the footage's own size, at `loc.key.size` when - /// one is set: resampled as the renderer would ([`FootageSource::frame_at_size`]), so the - /// pool keeps the reduced frame and a reduced-resolution render neither decodes nor resamples - /// a large still again for every frame (#1812: 21120-px plates at Quarter). + /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the + /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a + /// reduced-resolution render neither decodes nor resamples a large still again for every frame + /// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded + /// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one + /// decoded smaller is not (the renderer scales the layer instead). fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image { let (w, h) = loc.key.size; - if w == 0 || h == 0 || (img.width, img.height) != (footage.width, footage.height) || (w, h) == (img.width, img.height) { + let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9; + if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) { return img; } effectcraft_raster::resample(&img, w, h) diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 2e882d73d..0722e5d48 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -608,6 +608,14 @@ fn stills_at_reduced_sizes_are_resampled_once() { assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size"); assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again"); } + // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does; + // smaller ones are left as they are (the renderer scales the layer up instead). + let larger = Footage { width: 306, height: 48, ..f.clone() }; + let sized = MediaPool::new().frame_at_size(ItemId(1), &larger, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); + let smaller = Footage { width: 1226, height: 194, ..f.clone() }; + let kept = MediaPool::new().frame_at_size(ItemId(1), &smaller, Tick::ZERO, 306, 48).expect("frame"); + assert_eq!(*kept, *full); let _ = std::fs::remove_dir_all(&dir); } From e14cdd6009796c7ee56e82e8373c1d0ef0c896d8 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:21:14 +0300 Subject: [PATCH 0222/1386] Layer cache: animated masks with mask motion blur key the layer time Review: mask motion blur draws an animated mask at the shutter's other times, but the layer key hashed its value at this time only. Two frames with the same mask value (a hold before a move) shared a cached layer, with the wrong blur. This applied to solids, text and shapes already, and to stills since this PR. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 26 ++++++++++++++++++++++++++ crates/render/src/tests_mask_blur.rs | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 3fabe473a..17794d464 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -521,6 +521,27 @@ fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) } +/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸ +/// Motion Blur On, or Same As Layer with the layer blurred)? +fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool { + use effectcraft_project::{GroupKind, MaskMotionBlur}; + fn animated(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => !p.keys.is_empty() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) + } + ctx.comp.enable_motion_blur + && layer.masks().is_some_and(|m| { + m.groups().any(|g| match g.kind { + GroupKind::Mask { motion_blur, .. } => { + (motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g) + } + _ => false, + }) + }) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -635,6 +656,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } + // Mask motion blur draws animated masks at the shutter's other times too: their values at + // this time don't tell two frames apart (a hold before a move). + if mask_motion_blur(ctx, layer, blur) { + layer.layer_time(ctx.time).0.hash(&mut h); + } if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs index 12b88bbba..07bc1e95f 100644 --- a/crates/render/src/tests_mask_blur.rs +++ b/crates/render/src/tests_mask_blur.rs @@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] } +/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at +/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there. +#[test] +fn mask_motion_blur_keys_the_layer_cache_by_time() { + let (mut p, cid) = project(true); + let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0)); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") }; + m.get_mut("path").unwrap().keys = vec![ + Keyframe::new(Tick::ZERO, rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)), + ]; + let cache = crate::LayerCache::default(); + let render = |t: Tick, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() }); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, t) + }; + let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2)); + assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move"); + render(f1, true); + assert_eq!(render(f2, true), render(f2, false)); +} + #[test] fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(true); From 0605406c50da50f6d40a22c836221bba98adc63d Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Oct 2026 03:26:53 +0200 Subject: [PATCH 0223/1386] fix(linux): enable font-free Wayland window controls --- Cargo.lock | 41 +++++++++++++++++- NOTICE | 42 +++++++++++++++++++ apps/effectcraft/Cargo.toml | 3 ++ packaging/env.sh | 2 +- .../flatpak/ai.storyteller.effectcraft.yml | 1 + packaging/linux/package.sh | 2 + 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..0c26f0a07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,7 +1495,7 @@ dependencies = [ "effectcraft-raster", "kurbo", "linesweeper", - "tiny-skia", + "tiny-skia 0.12.0", ] [[package]] @@ -4581,6 +4581,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "log", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -5217,6 +5228,20 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -5228,7 +5253,18 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -6505,6 +6541,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/NOTICE b/NOTICE index 43bd2e2a9..718d12cef 100644 --- a/NOTICE +++ b/NOTICE @@ -20,3 +20,45 @@ The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR A 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. + +Linux Wayland window decorations +------------------------------- +sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita, +commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux +for vector window controls. Its title-font features are disabled: no Cantarell +font is embedded by this dependency and no font file is added to this repo. + +sctk-adwaita's MIT licence: + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +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. + +Bundled application fonts +------------------------- +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. + +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. diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 5ad6c80d1..52ed374ae 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -27,6 +27,9 @@ cpal = { workspace = true } log = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] +# Native GNOME/Wayland controls without a bundled title font. +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } + # Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in # Cargo.lock (accesskit, through eframe), so it adds no new crates. zbus = "5.19" diff --git a/packaging/env.sh b/packaging/env.sh index 6a1bfa286..f1943f777 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -47,7 +47,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done } diff --git a/packaging/linux/flatpak/ai.storyteller.effectcraft.yml b/packaging/linux/flatpak/ai.storyteller.effectcraft.yml index be9f898ed..7dd7a8d68 100644 --- a/packaging/linux/flatpak/ai.storyteller.effectcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.effectcraft.yml @@ -55,6 +55,7 @@ modules: build-commands: - cargo --offline build --release --locked -p effectcraft -p effectcraft-cli - install -Dm755 target/release/effectcraft /app/bin/effectcraft + - install -Dm644 NOTICE /app/share/doc/effectcraft/NOTICE - install -Dm755 target/release/effectcraft-cli /app/bin/effectcraft-cli - install -Dm644 packaging/linux/ai.storyteller.effectcraft.desktop /app/share/applications/ai.storyteller.effectcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.effectcraft.mime.xml /app/share/mime/packages/ai.storyteller.effectcraft.xml diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f149e663d..504d2c5b8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -106,6 +106,8 @@ if has appimage; then APPDIR="$WORK/EffectCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" + # Keep sctk-adwaita's MIT notice in the AppImage. + install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/effectcraft/NOTICE" ln -s usr/bin/effectcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" From de3fec28ed7a3d7a700a9d8ea307779dcda99a8e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:18:54 -0400 Subject: [PATCH 0224/1386] Release 0.13.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..ee9c99cf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1200,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.12.0" +version = "0.13.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.12.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.12.0" +version = "0.13.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.12.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.12.0" +version = "0.13.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1359,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1410,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1481,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.12.0" +version = "0.13.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1541,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.12.0" +version = "0.13.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.12.0" +version = "0.13.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.12.0" +version = "0.13.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1605,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.12.0" +version = "0.13.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.12.0" +version = "0.13.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1681,11 +1681,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.12.0" +version = "0.13.0" [[package]] name = "effectcraft-web" -version = "0.12.0" +version = "0.13.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.0" +version = "0.13.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 7af58f009..8c6f978ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.12.0" +version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 598f0d51c176243a0ed98cf08c0887bf9c104247 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 22:49:41 -0400 Subject: [PATCH 0225/1386] Clipboard: copied keyframes as After Effects keyframe text (#1908) Copying keys or unanimated properties of one layer records them as After Effects keyframe text: its Transform properties, one row per key with the frame in comp time (start offset and stretch applied) or a frameless row for a static value. EffectCraft's own keyframe clipboard is unchanged. --- crates/engine/src/commands/anim.rs | 1 + crates/engine/src/commands/edit.rs | 5 + crates/engine/src/commands/effect.rs | 1 + crates/engine/src/commands/mask_interp.rs | 1 + crates/engine/src/keyframe_text.rs | 83 ++++++++++++++ crates/engine/src/lib.rs | 7 ++ crates/engine/src/tests_keyframe_text.rs | 129 ++++++++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 crates/engine/src/keyframe_text.rs create mode 100644 crates/engine/src/tests_keyframe_text.rs diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 325845115..e711392e0 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { clip.push(KeyClip { layer: lid, path, keys }); } let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]); s.state.key_clipboard = clip; s.state.clip_is_keys = true; Ok(json!(n)) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 019d56d1d..ce5a068de 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -217,6 +217,7 @@ fn clear_clipboards(s: &mut Session) { s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.value_clipboard.clear(); + s.state.keyframe_text = None; s.state.clip_is_keys = false; } @@ -283,10 +284,12 @@ fn copy_properties(s: &mut Session) -> Result> { } let mut keys = s.state.selected_keys.clone(); let mut values = vec![]; + let mut statics = vec![]; for (lid, uid) in &leaves { let Some(l) = comp.layer(*lid) else { continue }; let Some(pr) = l.props.find(*uid) else { continue }; if pr.keys.is_empty() { + statics.push((*lid, *uid)); if let Some(path) = l.props.match_path_of(*uid) && !values.iter().any(|(p, _)| *p == path) { @@ -296,6 +299,7 @@ fn copy_properties(s: &mut Session) -> Result> { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } + let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics); clear_clipboards(s); let mut n = 0; if !keys.is_empty() { @@ -304,6 +308,7 @@ fn copy_properties(s: &mut Session) -> Result> { s.state.selected_keys = selected; n = r?.as_u64().unwrap_or(0); } + s.state.keyframe_text = text; let v = values.len(); s.state.value_clipboard = values; // Keys alone answer as `keys.copy` does (their count). diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..53813c51c 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -424,6 +424,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } s.state.clipboard.clear(); s.state.key_clipboard.clear(); + s.state.keyframe_text = None; s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.clip_is_keys = false; diff --git a/crates/engine/src/commands/mask_interp.rs b/crates/engine/src/commands/mask_interp.rs index 982468c8e..e2b53f8a4 100644 --- a/crates/engine/src/commands/mask_interp.rs +++ b/crates/engine/src/commands/mask_interp.rs @@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result { clip.push(crate::KeyClip { layer: lid, path, keys }); } s.state.key_clipboard = clip; + s.state.keyframe_text = None; s.state.clip_is_keys = true; // The same data as text (tab-separated, one row per frame) for other applications. let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime"); diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs new file mode 100644 index 000000000..0fd6f9cc2 --- /dev/null +++ b/crates/engine/src/keyframe_text.rs @@ -0,0 +1,83 @@ +//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other +//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After +//! Effects it covers one layer: its Transform properties, keyframed ones as one row per key +//! (frame number in comp time, then the value) and static ones as a single row with no frame. +//! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. + +use effectcraft_project::{Comp, LayerId, Project, Uid}; + +use crate::KeyRef; + +/// Transform properties in After Effects' order, with the unit each column is labelled with. +const CHANNELS: &[(&str, &str)] = &[ + ("poi", "pixels"), + ("anchor", "pixels"), + ("position", "pixels"), + ("scale", "percent"), + ("orientation", "degrees"), + ("rotationX", "degrees"), + ("rotationY", "degrees"), + ("rotation", "degrees"), + ("opacity", "percent"), +]; + +/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come +/// from one layer and at least one is a Transform property. +pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { + let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; + if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { + return None; + } + let layer = comp.layer(lid)?; + let fps = comp.frame_rate.as_f64(); + let mut blocks = String::new(); + for (id, unit) in CHANNELS { + let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; + if pr.three_d_only && !layer.is_3d() { + continue; + } + let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + let rows: Vec<(String, Vec)> = if !times.is_empty() { + let frame = |t| num(layer.comp_time(t).seconds() * fps); + times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect() + } else if statics.iter().any(|s| s.1 == pr.uid) { + vec![(String::new(), pr.value.components())] + } else { + continue; + }; + let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; + let columns = match dims { + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), + }; + // 3D layers show Rotation as Z Rotation. + let name = if *id == "rotation" && layer.is_3d() { "Z Rotation" } else { pr.name.as_str() }; + blocks.push_str(&format!("Transform\t{name}\n\tFrame\t{}\n", columns.join("\t"))); + for (frame, values) in rows { + let values: Vec = values.into_iter().map(num).collect(); + blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); + } + blocks.push('\n'); + } + if blocks.is_empty() { + return None; + } + let (w, h) = match effectcraft_render::eval::source_size(project, layer) { + (0, 0) => (comp.width, comp.height), + size => size, + }; + Some(format!( + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + num(fps), + num(comp.pixel_aspect) + )) +} + +/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`). +fn num(v: f64) -> String { + let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..223df7d15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod footage_reload; pub mod guard; pub mod history; pub mod jobs; +mod keyframe_text; pub mod learn; pub mod links; pub mod logging; @@ -244,6 +245,10 @@ pub struct EditorState { /// Paste sets the same properties of the selected layers. #[serde(skip)] pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// The copied keyframes and values as After Effects keyframe text (one layer's Transform + /// properties), for the system clipboard. + #[serde(skip)] + pub keyframe_text: Option, /// Copied effect instances, with comp-time key offsets from the earliest copied key. /// Paste adds them to the selected layers at the destination comp's CTI. #[serde(skip)] @@ -1264,6 +1269,8 @@ mod tests_import_project; #[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] +mod tests_keyframe_text; +#[cfg(test)] mod tests_keylight; #[cfg(test)] mod tests_limits; diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs new file mode 100644 index 000000000..ce3ec4899 --- /dev/null +++ b/crates/engine/src/tests_keyframe_text.rs @@ -0,0 +1,129 @@ +//! Copied keyframes as After Effects keyframe text for other applications (#1908). + +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::{KeyRef, Session}; + +/// A 1920×1080 23.976 fps comp with a 200×100 solid. +fn session() -> (Session, LayerId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + (s, LayerId(id)) +} + +fn frame(s: &Session, n: i64) -> Tick { + Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n) +} + +fn uid(s: &Session, lid: LayerId, path: &str) -> Uid { + s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid +} + +fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) { + let cid = s.active_comp_id().unwrap(); + f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap()); +} + +fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) { + s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap(); +} + +#[test] +fn copied_keys_are_after_effects_text_in_comp_frames() { + let (mut s, l) = session(); + // The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19. + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] { + let t = frame(&s, n); + add_key(&mut s, l, "transform/position", t, json!(v)); + } + let t = frame(&s, 5); + add_key(&mut s, l, "transform/rotation", t, json!(-45)); + let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation")); + // A subset: two of the three Position keys and the Rotation key. + s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!( + s.state.keyframe_text.as_deref(), + Some( + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ + Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\ + End of Keyframe Data\n" + ) + ); + // The internal clipboard still has the keys themselves, relative to the earliest. + let times: Vec> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect(); + assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]); +} + +#[test] +fn a_stretched_layer_writes_the_frames_its_keys_show_at() { + let (mut s, l) = session(); + edit_layer(&mut s, l, |l| l.stretch = 200.0); + let t = frame(&s, 6); + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0)); + add_key(&mut s, l, "transform/opacity", t, json!(100)); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t0\t0\n\t12\t100\n\n"), "{text}"); +} + +#[test] +fn copied_static_values_have_no_frame() { + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t\t100\t200\t0\n\n"), "{text}"); + assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value"); +} + +#[test] +fn a_copied_transform_group_lists_what_the_layer_shows() { + let (mut s, l) = session(); + let t = frame(&s, 24); + add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100])); + add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100])); + let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); + assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); + // 3D: Orientation, X / Y Rotation, and Rotation is Z Rotation. + edit_layer(&mut s, l, |l| l.switches.three_d = true); + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Z Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\tY degrees\tZ degrees\n\t\t0\t0\t0\n"), "{text}"); +} + +#[test] +fn keys_from_two_layers_have_no_text() { + let (mut s, a) = session(); + let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + for l in [a, b] { + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50)); + } + s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text"); + assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both"); + // A copied layer leaves no text behind. + s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }]; + s.execute("keys.copy", json!({})).unwrap(); + assert!(s.state.keyframe_text.is_some()); + s.state.selected_keys.clear(); + s.execute("edit.copy", json!({"layers": [a.0]})).unwrap(); + assert_eq!(s.state.keyframe_text, None); +} From 40d5466cc503927c05f97073310e5a0123ccaf14 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 22:49:45 -0400 Subject: [PATCH 0226/1386] Clipboard: Copy puts keyframe text on the system clipboard (#1908) Edit > Copy with keys or Transform properties selected puts the After Effects keyframe text on the system clipboard, for other applications such as AE2Blend, in place of the 'EffectCraft: N keyframes' note. Ctrl+V in EffectCraft still pastes from its own clipboard. --- crates/ui-egui/src/menus.rs | 13 +++++++++---- crates/ui-egui/tests/ui_keyframes.rs | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..38967187d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -853,10 +853,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V const COPY_COMMANDS: &[&str] = &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; -/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must -/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. -fn clipboard_note(s: &effectcraft_engine::Session) -> String { +/// Text for the system clipboard: copied keyframes and values as After Effects keyframe text, +/// for other applications (#1908), else a line naming what EffectCraft copied. The system +/// clipboard must hold something: with nothing on it, the windowing layer sends no paste event +/// for Ctrl+V. Paste in EffectCraft uses its own clipboard either way. +fn clipboard_text(s: &effectcraft_engine::Session) -> String { let st = &s.state; + if let Some(text) = &st.keyframe_text { + return text.clone(); + } let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); if st.clip_is_keys && !st.key_clipboard.is_empty() { n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") @@ -878,7 +883,7 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str if let Err(e) = &r { app.ui.status = e.clone(); } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { - ctx.copy_text(clipboard_note(&app.session)); + ctx.copy_text(clipboard_text(&app.session)); } let events = app.session.drain_events(); for (index, ev) in events.into_iter().enumerate() { diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index b47963d85..2eebb068f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -105,11 +105,15 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { _ => None, }) .collect(); - assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + // The system clipboard has the key as After Effects keyframe text (for other applications), + // so it isn't empty and Ctrl+V sends a paste event. + let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; + assert!(text.starts_with("Adobe After Effects 8.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t30\t100\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); - h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.input_mut().events.push(Event::Paste(text.clone())); h.run_steps(2); assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); } @@ -190,8 +194,18 @@ fn copy_and_paste_key_subsets_across_position_rotation_and_scale() { assert_eq!(h.state().session.state.selected_props.len(), 3); h.input_mut().events.push(Event::Copy); h.step(); - let note = format!("EffectCraft: {count} keyframes"); - assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e))); + let note = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .unwrap(); + let rows = note.lines().filter(|line| line.strip_prefix('\t').is_some_and(|row| row.starts_with(|c: char| c.is_ascii_digit()))).count(); + assert_eq!(rows, count, "one row per copied key: {note}"); h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0)); let undo_steps = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Paste(note)); From 4cf6580b19539e02863bdf03b071a55698ee144c Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 10:42:22 +0300 Subject: [PATCH 0227/1386] G5: a reduced still's cache key includes its nominal size (#1812) Review: two footage items of one file with different nominal sizes could share a sized entry, though one is resampled and the other kept as decoded. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 10 ++++++++-- crates/media/tests/footage.rs | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 062bdac54..09c9bb1d0 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -45,6 +45,9 @@ struct Key { matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). size: (u32, u32), + /// A still at a reduced `size`: the footage's nominal size, which decides whether it is + /// resampled (see `Inner::still_at_size`); (0, 0) otherwise. + nominal: (u32, u32), } struct Entry { @@ -678,7 +681,7 @@ impl Inner { FloatCurve::Gamma24 => 2, } << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -719,6 +722,9 @@ impl Inner { // A still asked for at its own size is the unsized frame (one decode, one entry). if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { loc.key.size = size; + if !movie { + loc.key.nominal = (footage.width, footage.height); + } } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead @@ -1074,7 +1080,7 @@ mod tests { fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) }; + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), budget); diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 0722e5d48..f510109ea 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -610,11 +610,12 @@ fn stills_at_reduced_sizes_are_resampled_once() { } // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does; // smaller ones are left as they are (the renderer scales the layer up instead). + // One pool, one file, one requested size: the two decisions don't share a cache entry. let larger = Footage { width: 306, height: 48, ..f.clone() }; - let sized = MediaPool::new().frame_at_size(ItemId(1), &larger, Tick::ZERO, 153, 24).expect("frame"); - assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); let smaller = Footage { width: 1226, height: 194, ..f.clone() }; - let kept = MediaPool::new().frame_at_size(ItemId(1), &smaller, Tick::ZERO, 306, 48).expect("frame"); + let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); + let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame"); assert_eq!(*kept, *full); let _ = std::fs::remove_dir_all(&dir); } From ac5799bd260fe7539f1fd019a4f29ef49e8a2cf3 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 11:43:30 +0300 Subject: [PATCH 0228/1386] =?UTF-8?q?G5:=20the=20playing=20prefetch=20wind?= =?UTF-8?q?ow=20starts=20at=20the=20first=20uncached=20frame,=20ahead=20?= =?UTF-8?q?=C3=97=203=20wide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looking up to the whole RAM preview for uncached frames let the workers spread over the preview while the playhead waited. On the GPU each worker then built a Classic 3D atlas for its own set of layers, and on a cold start of the #1812 project the device ran out of memory far more often (first pass 1-56 % of real time instead of 63-81 %). The window now keeps its old width but starts at the first frame ahead that isn't cached, so a loop or a restart still renders on at once. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 541081af1..2762188a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1238,34 +1238,34 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); // Never further ahead than the RAM preview holds: those frames would evict the ones the - // preview shows first, or be evicted before it gets there (#595). A playing preview - // looks that far for the next uncached frames: after a loop the frames just ahead are - // cached, and a shorter window left the workers idle until the playhead came near the - // end of the cached run. + // preview shows first, or be evicted before it gets there (#595). The window starts at the + // first uncached frame ahead: after a loop or a restart the frames just ahead are cached, + // and a window from the playhead left the workers idle until it came near the end of the + // cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render + // frames with different layers at once (on the GPU, an atlas each: #1812). let fit_ahead = self.frames_that_fit(&c, scale) - 1; - let window = match plan.filter(|_| self.playback.playing) { - Some(p) => fit_ahead.min((p.end - p.start) / p.step.max(1) + 1), - None => (ahead * 3).min(fit_ahead), + let frame = |k: i64| match plan { + Some(p) => p.frame_after(cur, k), + None => { + let f = cur + k; + Some(if f > wb { wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)) } else { f }) + } }; let cached = self.frames.cached_frames(&series); - for k in 0..window { + let uncached = |f: i64| cached.binary_search(&f).is_err(); + let first = match plan.filter(|_| self.playback.playing) { + Some(p) => (0..fit_ahead.min((p.end - p.start) / p.step.max(1) + 1)) + .map_while(|k| frame(k).map(|f| (k, f))) + .find(|&(_, f)| uncached(f)) + .map_or(0, |(k, _)| k), + None => 0, + }; + for k in first..(first + ahead * 3).min(fit_ahead) { if queued >= ahead as usize || !video { break; } - let f = match plan { - Some(p) => match p.frame_after(cur, k) { - Some(f) => f, - None => break, - }, - None => { - let mut f = cur + k; - if f > wb { - f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); - } - f - } - }; - if cached.binary_search(&f).is_err() { + let Some(f) = frame(k) else { break }; + if uncached(f) { self.request_frame(cid, f, scale); queued += 1; } From 9c5494fd296acfecd0d29b55de4da25d882a220a Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 12:30:25 +0300 Subject: [PATCH 0229/1386] G5: a layer rendered by two workers at once is shared from the cache (#1812) Preview workers that start together miss the layer cache together, and each kept its own copy of every layer; on the GPU each copy was uploaded and packed into its own Classic 3D atlas, which ran the device out of memory on a cold start. LayerCache::insert now keeps the first buffer of a key and returns it, and the renderer goes on with the returned buffer, so the workers share one. Renders don't wait for each other: on the shared frame pool a blocking wait could end up beneath the work it waits for (tried, it stalled playback for the wait's timeout). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 53 +++++++++++++++++++++++++++++++------- crates/render/src/lib.rs | 39 +++++++++++++++++----------- crates/render/src/tests.rs | 29 +++++++++++++++++++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..26666c3ea 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -168,15 +168,23 @@ impl LayerCache { // Disk cache: a hit comes back into memory. let (dc, dk) = self.disk_key(key)?; let buf = dc.get_layer(dk)?; - self.insert_mem(key, buf.clone()); - Some(buf) + Some(self.insert_mem(key, buf)) } } } - pub fn insert(&self, key: u64, buf: Arc) { + /// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first + /// when another render of the same layer got there before (#1812: preview workers starting + /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads + /// and packs it once instead of once per worker, which ran it out of memory). Renders don't + /// wait for each other: on the frame workers' shared thread pool a wait can end up under + /// the very work it waits for. + pub fn insert(&self, key: u64, buf: Arc) -> Arc { if self.gated() { - return; + return buf; + } + if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + return kept; } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); if let Some((dc, dk)) = self.disk_key(key) { @@ -185,20 +193,24 @@ impl LayerCache { dc.put_layer(dk, &buf); } } - self.insert_mem(key, buf); + self.insert_mem(key, buf) } - fn insert_mem(&self, key: u64, buf: Arc) { + /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first + /// or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc) -> Arc { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return }; + let Ok(mut g) = self.inner.lock() else { return buf }; if bytes > g.budget / 4 { - return; + return buf; } g.clock += 1; let now = g.clock; - if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { - g.bytes -= old.bytes; + if let Some(e) = g.map.get_mut(&key) { + e.last_use = now; + return e.buf.clone(); } + g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. @@ -214,6 +226,7 @@ impl LayerCache { } } } + buf } /// The memory budget in bytes. @@ -668,3 +681,23 @@ pub fn derive(key: u64, i: u64) -> u64 { i.hash(&mut h); h.finish() } + +#[cfg(test)] +mod shared_insert_tests { + use std::sync::Arc; + + use super::LayerCache; + use effectcraft_effects::Buf; + use effectcraft_raster::Image; + + /// Two renders of one layer kept under one key: both use the buffer kept first. + #[test] + fn the_buffer_kept_first_is_shared() { + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let (a, b) = (buf(), buf()); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..3876cb7ae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1478,10 +1478,11 @@ impl<'a> Renderer<'a> { let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); - if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) { - cache.insert(k, nb.clone()); + // The buffer the cache keeps (another worker's, if it kept one first). + match (self.cache_to_keep(), key) { + (Some(cache), Some(k)) => cache.insert(k, nb), + _ => nb, } - nb } fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { @@ -1496,9 +1497,11 @@ impl<'a> Renderer<'a> { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, flat.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let flat = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, flat), + _ => flat, + }; Some((flat, key)) } @@ -1527,9 +1530,11 @@ impl<'a> Renderer<'a> { } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some((buf, key)) } @@ -1552,9 +1557,11 @@ impl<'a> Renderer<'a> { buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); } let buf = Arc::new(buf); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some(buf) } @@ -2215,9 +2222,11 @@ impl<'a> Renderer<'a> { let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, foot.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let foot = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, foot), + _ => foot, + }; Some(foot) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..f290e31ca 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1010,3 +1010,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() { let (a, b) = (particles_x(&here), particles_x(&panned)); assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}"); } + +/// #1812: preview workers that render a layer at once end up with one buffer (the one kept +/// first), so the GPU compositor uploads and packs it once, not once per worker. +#[test] +fn workers_rendering_a_layer_at_once_share_one_buffer() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200); + add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let bufs: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = Some(&cache); + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.layer_buf(&ctx, &c.layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); + assert_eq!(cache.stats().entries, 1); +} From 8d887fd6dad5948b5ffe47b87dbfedb57235b1c8 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 12:18:46 +0200 Subject: [PATCH 0230/1386] Layer: replace a layer's source with Alt-drag and Ctrl+Alt+/ (#991, #813) Alt/Option-drag a Project item onto a Timeline layer, or press Ctrl+Alt+/ (Cmd+Opt+/) with layers and an item selected, to swap the layer's source. The layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches, parenting and track matte; a source with audio adds Audio properties when missing. Precompositions can't be nested inside themselves. AVLayer.replaceSource now runs the same engine command and closes the replaceSource part of #813. --- crates/engine/src/commands/layer.rs | 110 ++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_replace_source.rs | 158 ++++++++++++++++++++++ crates/script/src/prelude.js | 9 +- crates/script/src/tests.rs | 31 +++++ crates/ui-egui/src/panels/timeline.rs | 64 ++++++++- crates/ui-egui/tests/ui_drag_drop.rs | 41 ++++++ docs/footage.md | 9 ++ 8 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/tests_replace_source.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..4d55859fa 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, - limits, + Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, + TrackMatte, limits, }; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0})) } +/// A layer whose source is an item (footage, precomposition or solid), as in After Effects' +/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to +/// replace. +fn replaceable_layer(l: &Layer) -> bool { + matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) +} + +/// The source a Project item gives a layer, or why it can't replace one. +fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result { + const CMD: &str = "layer.replaceSource"; + match &it.kind { + ItemKind::Comp(_) => Ok(LayerSource::Comp { item }), + ItemKind::Footage(f) if f.kind == FootageKind::Data => { + Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData")) + } + ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")), + ItemKind::Footage(_) => Ok(LayerSource::Footage { item }), + ItemKind::Solid(_) => Ok(LayerSource::Solid { item }), + ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")), + } +} + +/// Whether a source brings audio properties (precompositions always can; footage when it has an +/// audio stream). +fn source_has_audio(project: &Project, source: &LayerSource) -> bool { + match source { + LayerSource::Comp { .. } => true, + LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)), + _ => false, + } +} + +fn has_replace_source(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + let c = s.active_comp().ok_or("no composition")?; + if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) { + return Err("select a footage, composition or solid layer".into()); + } + if s.state.project_selection.is_empty() { + return Err("select a footage item, a composition or a solid in the Project panel".into()); + } + Ok(()) +} + +/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After +/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms, +/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source +/// changes; precompositions bring their Essential Properties group through `sync_project`, and a +/// source with audio adds an Audio group when the layer has none. +fn replace_source(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.replaceSource"; + let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, CMD)?; + if ids.is_empty() { + return Err(bad(CMD, "select a layer first")); + } + let item = match p.get("item") { + Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?, + _ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?, + }; + let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?; + let source = replacement_source(item, it)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for lid in &ids { + let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?; + if !replaceable_layer(l) { + return Err(bad(CMD, format!("“{}” has no source item to replace", l.name))); + } + } + if let LayerSource::Comp { item: new } = &source + && s.project.comp_contains(*new, cid) + { + return Err(bad(CMD, "a composition can't contain itself")); + } + let has_audio = source_has_audio(&s.project, &source); + let source_for_edit = source.clone(); + s.edit("Replace Layer Source", None, |proj, _| { + for lid in &ids { + let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none()); + let audio = if needs_audio { + let mut next = Ids(&mut proj.next_id); + Some(build::audio(&mut next)) + } else { + None + }; + let l = layer_mut(proj, cid, *lid)?; + l.source = source_for_edit.clone(); + if let Some(audio) = audio { + l.props.children.push(audio.into()); + } + } + Ok(()) + })?; + Ok(json!({"layers": ids.iter().map(|l| l.0).collect::>(), "item": item.0})) +} + fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -1628,6 +1725,15 @@ pub fn specs() -> Vec { has_comp, add_item ), + cmd!( + "layer.replaceSource", + "Replace Layer Source", + [], + Some("Cmd+Alt+/"), + "{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}", + has_replace_source, + replace_source + ), cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..96b3bf67d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1290,6 +1290,8 @@ mod tests_proxy; #[cfg(test)] mod tests_quick_offset; #[cfg(test)] +mod tests_replace_source; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests_replace_source.rs new file mode 100644 index 000000000..a5cb252ce --- /dev/null +++ b/crates/engine/src/tests_replace_source.rs @@ -0,0 +1,158 @@ +//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/ +//! replace an AV layer's source while every other layer property stays. + +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid}; +use serde_json::json; + +use crate::Session; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone() +} + +fn solid(s: &mut Session, name: &str) -> u64 { + s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap() +} + +fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId { + let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() }; + Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f)) +} + +/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch, +/// switches, blending, parenting, track matte and the whole property tree (keys, expression, +/// effect, mask) stay. +#[test] +fn replace_source_keeps_every_layer_property() { + let mut s = comp(); + let a = solid(&mut s, "Hero"); + let parent = solid(&mut s, "Parent"); + let matte = solid(&mut s, "Matte"); + s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap(); + s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap(); + s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap(); + s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap(); + s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap(); + s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap(); + s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap(); + let before = layer(&s, a); + let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item }); + assert_eq!(after.name, before.name); + assert_eq!(after.label, before.label); + assert_eq!(after.comment, before.comment); + assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch)); + assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency)); + assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte)); + assert_eq!(after.props, before.props); + assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source"); +} + +/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio +/// group when the layer has none, and an existing Audio group is kept when the next source has no +/// audio. +#[test] +fn replace_source_crosses_types_and_adds_audio() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let before = layer(&s, a); + s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) }); + assert_eq!(after.name, before.name); + assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item: footage }); + assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source"); + let new_solid = Arc::make_mut(&mut s.project).add_item( + "New Solid", + Label::None, + None, + ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }), + ); + s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid }); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage }); +} + +/// Folders, data files, 3D models, non-source layers and composition cycles are refused before +/// anything is edited. +#[test] +fn replace_source_refuses_invalid_sources_targets_and_cycles() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder); + let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false); + let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false); + for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] { + let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string(); + assert!(err.contains(needle), "{err}"); + } + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("no source item"), "{err}"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string(); + assert!(err.contains("contain"), "{err}"); +} + +/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected +/// layer; locked layers are left alone (and an all-locked selection is an error). +#[test] +fn replace_source_uses_the_project_selection_and_skips_locked_layers() { + let mut s = comp(); + let a = solid(&mut s, "A"); + let b = solid(&mut s, "B"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + s.state.project_selection = vec![footage]; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap(); + assert_eq!(r["item"], json!(footage.0)); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let old_b = layer(&s, b).source; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap(); + assert_eq!(r["layers"], json!([a])); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert_eq!(layer(&s, b).source, old_b); + let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); +} + +/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax). +#[test] +fn replace_source_is_bound_to_ctrl_alt_slash() { + let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable"); + assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults); +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2eeca86bf..ba5a3af52 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1301,7 +1301,14 @@ AVLayer.prototype.constructor = AVLayer; } P.sourcePointToComp = function (p) { return pointIn(this, p, true); }; P.compPointToSource = function (p) { return pointIn(this, p, false); }; - P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; + // Replace the layer's source item, keeping every layer property (After Effects' + // AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are + // replaced, so the property tree and the layer's name stay and expressions keep resolving. + P.replaceSource = function (newSource, fixExpressions) { + if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item"); + __call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions }); + return null; + }; P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; })(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6f87f7572..0e1e509c3 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -963,6 +963,37 @@ fn footage_proxies_sequences_and_conform_rate() { let _ = std::fs::remove_dir_all(&dir); } +/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and +/// properties; a folder is not a source. +#[test] +fn av_layer_replace_source_keeps_the_layer() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25); + var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1); + layer.transform.position.setValue([10, 20]); + layer.startTime = 1; + var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25); + layer.replaceSource(nested, false); + [layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem, + layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers] + "#, + ); + assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1])); + let bad = run_code( + &mut s, + r#" + var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25); + var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1); + layer.replaceSource(app.project.items.addFolder("F"), false); + "#, + "replace-source.jsx", + ); + assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}"); +} + /// A flat green footage source, so `sampleImage` on a footage layer has known pixels. struct Flat; impl effectcraft_engine::render::FootageSource for Flat { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..5a7f40d3b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1382,6 +1382,26 @@ fn layer_drop_id() -> egui::Id { egui::Id::new("tl-layer-drop") } +/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` +/// accepts): compositions, footage and solids; not folders, data files or 3D models. +fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { + match &it.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => !matches!(f.kind, FootageKind::Data | FootageKind::Model), + ItemKind::Folder => false, + } +} + +/// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and +/// the item wouldn't put the composition inside itself. +fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { + app.session.project.item(item).is_some_and(replaceable_item) + && !app.session.project.comp_contains(item, cid) + && comp + .layer(lid) + .is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })) +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); @@ -3356,7 +3376,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { + // Alt/Option-drag from the Project panel replaces the source of the layer under the + // pointer (After Effects). The shortcut replaces every selected layer instead. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_)); + if alt_item { + if let crate::panels::DragPayload::Item(id) = payload.as_ref() { + let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); + if let Some(l) = target + && replace_source_target(app, cid, ItemId(*id), l, &comp) + && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b)) + { + let lit = span.intersect(rows_rect); + let rp = p.with_clip_rect(rows_rect); + rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18)); + rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source"); + if released { + actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false}))); + } + } + } + } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); // The layer the effect would land on (its row and its open properties) is the one // that lights up, not the whole timeline. @@ -4264,6 +4304,26 @@ fn value_editor( mod tests { use super::*; + /// Alt/Option-drag takes the same Project items `layer.replaceSource` does: compositions, + /// footage and solids, never folders, data files or 3D models. + #[test] + fn replace_source_takes_visual_items_only() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, Project, Solid}; + use effectcraft_engine::time::{FrameRate, Tick}; + let mut p = Project::default(); + let l = Label::None; + let comp = p.add_item("C", l, None, ItemKind::Comp(Comp::new(10, 10, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)).into())); + let folder = p.add_item("F", l, None, ItemKind::Folder); + let still = p.add_item("S", l, None, ItemKind::Footage(Footage { kind: FootageKind::Still, ..Default::default() })); + let data = p.add_item("D", l, None, ItemKind::Footage(Footage { kind: FootageKind::Data, ..Default::default() })); + let model = p.add_item("M", l, None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() })); + let solid = p.add_item("So", l, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 10, height: 10, pixel_aspect: 1.0 })); + for (id, ok) in [(comp, true), (still, true), (solid, true), (folder, false), (data, false), (model, false)] { + assert_eq!(replaceable_item(p.item(id).unwrap()), ok, "{id:?}"); + } + } + /// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest. #[test] fn the_waveform_row_twirls_with_its_tree() { diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9b66f745c..e405bf777 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() { assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time"); } +/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer +/// instead of adding a layer (#991). +#[test] +fn alt_dragging_a_project_item_replaces_the_layer_source() { + let (mut h, clip) = harness(); + let top = layer_id(&h, "Top"); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let row = rect(&h, &format!("timeline.layer.{top}.row")); + let before = stack(&h); + drag(&mut h, item, row.center(), Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap(); + assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }); + assert_eq!(layer.name, "Top", "the layer keeps its name"); +} + +/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel +/// selection (#991). +#[test] +fn ctrl_alt_slash_replaces_every_selected_layer_source() { + let (mut h, clip) = harness(); + let top = layer_id(&h, "Top"); + let middle = layer_id(&h, "Middle"); + h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap(); + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)]; + h.run_steps(3); + let modifiers = Modifiers { command: true, alt: true, ..Default::default() }; + for pressed in [true, false] { + h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers }); + } + h.run_steps(3); + let comp = h.state().session.active_comp().unwrap(); + for lid in [top, middle] { + assert_eq!( + comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source, + effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) } + ); + } + assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added"); +} + /// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was /// dropped (above the selected layer, like any new layer). #[test] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e6..e142fc4a1 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -110,6 +110,15 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la Importing a layered file as a composition refreshes the decoded sources used by its layers, including files overwritten at the same path and byte size. +- **Replace a layer's source** without rebuilding the layer: Alt-drag (Option-drag on macOS) a + footage item, composition or solid from the Project panel onto a layer in the Timeline. The + layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches, + parenting and track matte; only its source changes. A source with audio adds Audio properties + when the layer has none. To replace several selected layers with the selected Project item, press + Ctrl+Alt+/ (Cmd+Opt+/). Compositions, footage and solids are interchangeable; folders, data files + and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a + layer is one layer at a time, as in After Effects. + Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). From c7eb48dc3aebc3c768b03eb9fe19212b382dc4c3 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 13:59:01 +0200 Subject: [PATCH 0231/1386] Layer: reveal layer sources in the Project panel (#1809) --- crates/engine/src/commands/layer_menu.rs | 30 +++++++++++---- crates/engine/src/tests_menu_cmds.rs | 31 +++++++++++++++ crates/ui-egui/src/menus.rs | 12 ++++++ crates/ui-egui/src/panels/project.rs | 34 ++++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 25 +++++++++--- crates/ui-egui/src/panels/viewer.rs | 1 + .../ui-egui/tests/ui_blank_context_menus.rs | 38 +++++++++++++++++++ docs/footage.md | 10 +++++ 8 files changed, 168 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 9cac80412..cdc1dae41 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { } fn reveal_source(s: &mut Session, p: &Value) -> Result { - let item = source_item(s, p, "layer.revealSource")?; - s.state.project_selection = vec![item]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": item.0})) + let (cid, ids) = layers_p(s, p)?; + let mut items: Vec = Vec::new(); + { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for layer in ids.iter().filter_map(|id| comp.layer(*id)) { + if let Some(item) = layer.source.item() + && s.project.item(item).is_some() + && !items.contains(&item) + { + items.push(item); + } + } + } + if items.is_empty() { + return Err(bad("layer.revealSource", "the selected layers have no source item")); + } + s.state.project_selection = items.clone(); + let raw: Vec = items.iter().map(|item| item.0).collect(); + frontend(s, "project.reveal", &json!({"items": raw}))?; + Ok(json!({"item": items[0].0, "items": raw})) } fn reveal_comp(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.project_selection = vec![cid]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": cid.0})) + frontend(s, "project.reveal", &json!({"items": [cid.0]}))?; + Ok(json!({"item": cid.0, "items": [cid.0]})) } impl Session { @@ -909,7 +925,7 @@ pub fn specs() -> Vec { cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source), cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder), - cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source), + cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source), cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp), cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors), cmd!( diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd40..fd19300d0 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -725,6 +725,37 @@ fn reveal_properties() { assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps")); } +#[test] +fn reveal_layer_source_selects_each_layers_source_item() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let ia = layer(&s, a).source.item().unwrap(); + let ib = layer(&s, b).source.item().unwrap(); + assert_ne!(ia, ib); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["item"], ia.0); + assert_eq!(r["items"], json!([ia.0, ib.0])); + assert_eq!(s.state.project_selection, vec![ia, ib]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]); + // A null has no source. Alone it is refused; in a mixed selection only the sources are revealed. + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [n]})).unwrap(); + assert!(s.execute("layer.revealSource", json!({})).is_err()); + s.execute("layer.select", json!({"layers": [n, a]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["items"], json!([ia.0])); + assert_eq!(s.state.project_selection, vec![ia]); + // Reveal Composition in Project opens the same panel and folders. + let cid = s.active_comp_id().unwrap(); + s.drain_events(); + let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap(); + assert_eq!(r["item"], cid.0); + assert_eq!(s.state.project_selection, vec![cid]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]); +} + #[test] fn sequence_layers() { let mut s = comp(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..36a85aef5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -939,6 +939,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } // View ▸ New Viewer. "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), + // Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening + // its folders and clearing the search so the row is visible. + "project.reveal" => { + let ids: Vec = p + .get("items") + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect()) + .or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)])) + .unwrap_or_default(); + crate::panels::project::reveal(app, ctx, &ids); + json!({"items": ids.iter().map(|item| item.0).collect::>()}) + } // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, // A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor. diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c4..ef34798b6 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -224,6 +224,11 @@ fn focused_row_id() -> egui::Id { egui::Id::new("project-focused-row") } +/// The item whose selection the panel last scrolled into view. +fn selection_seen_id() -> egui::Id { + egui::Id::new("proj-sel-seen") +} + /// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys /// apply. Any other focused widget (a text field, a button reached with Tab) keeps them. fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool { @@ -347,6 +352,33 @@ pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { rows } +/// Bring `items` into view: clear the panel's search, open every folder holding them, select +/// them, and show and focus the Project panel. Used by Reveal Layer Source in Project and +/// Reveal Composition in Project, so the item is visible even in a large, filtered project. +pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[ItemId]) { + let items: Vec = items.iter().copied().filter(|item| app.session.project.item(*item).is_some()).collect(); + if items.is_empty() { + return; + } + for item in &items { + let mut parent = app.session.project.item(*item).and_then(|item| item.parent); + let mut depth = 0; + while let Some(folder) = parent { + app.ui.project_open_folders.insert(folder.0); + parent = app.session.project.item(folder).and_then(|item| item.parent); + depth += 1; + if depth > 256 { + break; + } + } + } + app.ui.project_search.clear(); + app.session.state.project_selection = items; + // Force the panel's selection scroll on its next draw, even when the item was already selected. + ctx.data_mut(|d| d.remove_temp::>(selection_seen_id())); + app.show_panel(crate::dock::PanelKind::Project); +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); @@ -562,7 +594,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // A selection made elsewhere (a command, an agent, the viewer) scrolls into view. - let sel_key = egui::Id::new("proj-sel-seen"); + let sel_key = selection_seen_id(); let sel_now = app.session.state.project_selection.last().map(|i| i.0); if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) { ctx.data_mut(|d| d.insert_temp(sel_key, sel_now)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..44a24b140 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3883,18 +3883,25 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec } } -/// Which Create ▸ conversions the layer context menu offers, from the targeted layer's kind. +/// Which Create ▸ conversions and Reveal ▸ actions the layer context menu offers, from the +/// targeted layer's kind. #[derive(Clone, Copy, Default)] pub(crate) struct LayerMenuCaps { /// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer. pub vector: bool, /// A text layer: Create Shapes from Text / Create Masks from Text. pub text: bool, + /// The layer has a source item to reveal in the Project panel. + pub source: bool, } impl LayerMenuCaps { pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self { - Self { vector: vector_footage_layer(project, layer), text: matches!(layer.source, LayerSource::Text) } + Self { + vector: vector_footage_layer(project, layer), + text: matches!(layer.source, LayerSource::Text), + source: layer.source.item().is_some_and(|item| project.item(item).is_some()), + } } } @@ -3916,6 +3923,10 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; + // The menu acts on the whole selection: offer Reveal when any target has a source, even if + // the layer under the pointer is a text, shape or null layer. + let any_source = targets.iter().any(|id| comp.layer(LayerId(*id)).is_some_and(|l| l.source.item().is_some())); + let caps = LayerMenuCaps { source: caps.source || any_source, ..caps }; crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } @@ -3951,6 +3962,9 @@ pub(crate) fn layer_menu( item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); + if caps.source { + item(ui, "Reveal Layer Source in Project", "layer.revealSource", json!({"layers": layers})); + } if caps.vector { item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); } @@ -4656,20 +4670,21 @@ mod tests { /// the categories the Effect menu does; the actions it records stay the same. #[test] fn the_layer_context_menu_draws_in_the_interface_language() { - let caps = LayerMenuCaps { vector: true, text: false }; + let caps = LayerMenuCaps { vector: true, text: false, source: true }; let (mut auto, mut actions) = (crate::automation::Registry::default(), Vec::new()); crate::i18n::set_current("zh-hans"); let menu = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); let effects = drawn_text(|ui| effects_submenu(ui, &[7], &mut |_: &mut egui::Ui, _: &str, _: &str, _: serde_json::Value| {})); crate::i18n::set_current("en"); let english = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); - for label in ["新建", "图层设置...", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] { + for label in ["新建", "图层设置...", "在项目中显示图层源", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] + { assert!(menu.iter().any(|t| t == label), "{label} missing from {menu:?}"); } assert!(!menu.iter().any(|t| t.is_ascii()), "English left in {menu:?}"); assert!(effects.iter().any(|t| t == "模糊和锐化"), "{effects:?}"); assert!(!effects.iter().any(|t| t == "Blur & Sharpen"), "{effects:?}"); - for label in ["Layer Settings…", "Duplicate", "Delete"] { + for label in ["Layer Settings…", "Reveal Layer Source in Project", "Duplicate", "Delete"] { assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}"); } assert!(actions.is_empty()); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696f..61e7336f9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1311,6 +1311,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer); caps.vector |= c.vector; caps.text |= c.text; + caps.source |= c.source; caps }, ); diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 20f7467cc..9e7363dbc 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,5 +1,6 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; +use effectcraft_engine::project::ItemKind; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable as _}; @@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() { assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some())); } +/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the +/// Project panel's search, opens the folders holding the item and selects it. +#[test] +fn timeline_layer_context_menu_reveals_the_source_in_project() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap(); + let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap(); + h.state_mut().session.execute("project.select", json!({"items": []})).unwrap(); + h.state_mut().ui.project_open_folders.clear(); + h.state_mut().ui.project_search = "no such item".into(); + h.run_steps(3); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden"); + let r = rect(&h, &format!("timeline.layer.{layer}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid]); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(h.state().ui.project_search.is_empty(), "the search is cleared"); + assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open"); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible"); + // In a mixed selection the command still appears when the right-clicked layer has no source, + // because the menu acts on the solid layer too. + let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{shape}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape"); +} + /// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that /// was right-clicked (not the one selected before, and not the composition) at the current time. #[test] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e6..660f8ac3d 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -70,6 +70,16 @@ next to it. Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps, `primaryComp` (the only root comp, if there is one) and any missing media. +## Finding a layer's source in the Project panel + +Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in +Project** (also Layer ▸ Reveal ▸ Reveal Layer Source in Project). The Project panel opens, its +search is cleared, the folders holding the source open, and the source item is selected. + +Layers whose image is generated inside the composition — text, shape, null, camera, light and +3D primitive layers — have no Project item, so the command is not offered for them. Adjustment +layers do have one: the solid that backs them. + ## Interpret Footage Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…** From 14453386ac201793756d9fad0486dbfc0e6bb080 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 14:46:05 +0200 Subject: [PATCH 0232/1386] Viewer: keep hidden AV layers' controls visible (#1769) --- crates/ui-egui/src/panels/viewer.rs | 9 ++-- crates/ui-egui/tests/ui_viewer.rs | 68 +++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696f..b8d8061bc 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1011,9 +1011,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col))); } } - // A layer whose Video switch is off has no box, handles or paths in the viewer (a hidden - // null disappears from the comp, as in After Effects), even while it is selected. - for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && l.switches.video) { + // The eye hides a layer's pixels, not its controls: a selected AV layer keeps its box, + // handles, anchor, motion path and mask/shape paths so it stays editable over the visible + // artwork (#1769). Nulls, cameras and lights have no pixels and disappear from the viewer + // when hidden (#1332/#1642). Picking still uses `selectable_layers`, so a hidden layer is + // selected from the Timeline; its controls are interactive once selected. + for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) { let col = if label_handles { t.label(l.label) } else { t.accent }; // Motion path of animated position (keyframes and spatial tangents are draggable). ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 2a9162a59..14464d189 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -759,6 +759,74 @@ fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() { } } +/// #1769: the eye hides a layer's pixels, not the controls of a selected AV layer: a hidden shape +/// layer keeps its transform box, anchor and shape path, and its handles still scale it. +#[test] +fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() { + let mut h = harness(); + let (layer, path) = { + let r = h + .state_mut() + .session + .execute( + "shape.newPath", + json!({ + "vertices": [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]], + "closed": true, + "fill": false, + "stroke": "#ffffff", + "strokeWidth": 4 + }), + ) + .unwrap(); + (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap()) + }; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().session.state.selected_vertices.clear(); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "video", "value": false})).unwrap(); + h.run_steps(2); + let shown = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone(); + assert!(!shown.switches.video); + assert!(shown.source.is_av()); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(layer)]); + assert!(h.state().auto.find(&format!("viewer.handle.{layer}.0")).is_some(), "a hidden shape keeps its transform box"); + assert!(h.state().auto.find(&format!("viewer.anchor.{layer}")).is_some(), "and its anchor"); + assert!(h.state().auto.find(&format!("viewer.shapePath.{path}.vertex.0")).is_some(), "and its shape path"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/scale").unwrap().value.as_vec3() + }; + let before = scale(&h); + let from = rect(&h, &format!("viewer.handle.{layer}.5")).center(); + drag(&mut h, from, from + vec2(24.0, 0.0)); + assert_ne!(scale(&h), before, "a hidden layer's handle still scales it"); +} + +/// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. +#[test] +fn hidden_masked_layer_keeps_its_mask_path_editable() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.state.selected_vertices.clear(); + s.execute("layer.setSwitch", json!({"layers": [plate], "switch": "video", "value": false})).unwrap(); + m + }; + h.run_steps(2); + assert!(h.state().auto.find(&format!("viewer.handle.{plate}.0")).is_some(), "a hidden masked layer keeps its transform box"); + let vertex = format!("viewer.mask.{mask}.vertex.0"); + assert!(h.state().auto.find(&vertex).is_some(), "and its mask path"); + let before = mask_path(&h, "Plate"); + let from = rect(&h, &vertex).center(); + drag(&mut h, from, from + vec2(15.0, 12.0)); + let after = mask_path(&h, "Plate"); + assert_ne!(after.vertices[0], before.vertices[0], "the hidden layer's mask vertex can still be moved"); + assert_eq!(after.vertices[1..], before.vertices[1..]); +} + #[test] fn path_tools_keep_bounds_on_layers_without_visible_paths() { let mut h = harness(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ccce70e0f..d76e573fd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769) | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | From 9313784304fdd8fbdbc6daaad94a67dd86bcf781 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:47:51 -0400 Subject: [PATCH 0233/1386] Clipboard: Orientation and Rotation headers as After Effects writes them (#1908) --- crates/engine/src/keyframe_text.rs | 7 ++++--- crates/engine/src/tests_keyframe_text.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 0fd6f9cc2..8fca3c343 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -48,13 +48,14 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( continue; }; let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; + // After Effects labels only the first of Orientation's three columns, and names Z + // Rotation plain Rotation (as AE2Blend expects). let columns = match dims { 1 => vec![unit.to_string()], + _ if *id == "orientation" => vec![format!("X {unit}")], _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; - // 3D layers show Rotation as Z Rotation. - let name = if *id == "rotation" && layer.is_3d() { "Z Rotation" } else { pr.name.as_str() }; - blocks.push_str(&format!("Transform\t{name}\n\tFrame\t{}\n", columns.join("\t"))); + blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); for (frame, values) in rows { let values: Vec = values.into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index ce3ec4899..34966269b 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -98,14 +98,15 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); - // 3D: Orientation, X / Y Rotation, and Rotation is Z Rotation. + // 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three + // values sit under one label, as After Effects writes them. edit_layer(&mut s, l, |l| l.switches.three_d = true); s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); - assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Z Rotation", "Opacity"]); - assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\tY degrees\tZ degrees\n\t\t0\t0\t0\n"), "{text}"); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\n\t\t0\t0\t0\n"), "{text}"); } #[test] From 6b77e6a1a715afd61dd777b8a9ad51245831a987 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:49:33 -0400 Subject: [PATCH 0234/1386] Clipboard: keyframe text leaves out Transform properties the layer doesn't use (#1908) --- crates/engine/src/keyframe_text.rs | 15 ++++++++++-- crates/engine/src/tests_keyframe_text.rs | 29 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 8fca3c343..793d0cfb8 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! (frame number in comp time, then the value) and static ones as a single row with no frame. //! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{Comp, LayerId, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, Layer, LayerId, Project, Uid}; use crate::KeyRef; @@ -33,7 +33,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( let mut blocks = String::new(); for (id, unit) in CHANNELS { let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; - if pr.three_d_only && !layer.is_3d() { + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { continue; } let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); @@ -76,6 +76,17 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( )) } +/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at +/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z +/// Position (which this text doesn't cover). +fn active(layer: &Layer, id: &str) -> bool { + match id { + "poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest, + "position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()), + _ => true, + } +} + /// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`). fn num(v: f64) -> String { let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 }); diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 34966269b..81ca97bd4 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -109,6 +109,35 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\n\t\t0\t0\t0\n"), "{text}"); } +fn copy_transform(s: &mut Session, lid: LayerId) -> String { + let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +fn names(text: &str) -> Vec<&str> { + text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect() +} + +#[test] +fn properties_the_layer_does_not_use_are_left_out() { + // Separate Dimensions: the old Position no longer moves the layer. + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap(); + let text = copy_transform(&mut s, l); + assert!(!names(&text).contains(&"Position"), "{text}"); + // A one-node camera has no Point of Interest; a two-node one does. + let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, cam); + assert!(!names(&text).contains(&"Point of Interest"), "{text}"); + s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap(); + let text = copy_transform(&mut s, cam); + assert!(names(&text).contains(&"Point of Interest"), "{text}"); +} + #[test] fn keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 22ddf7bb99ed79333d14962b31a32fd9b9576af0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:51:09 -0400 Subject: [PATCH 0235/1386] Clipboard: keyframe text for a 2D layer writes the Z it shows (#1908) --- crates/engine/src/keyframe_text.rs | 8 +++++++- crates/engine/src/tests_keyframe_text.rs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 793d0cfb8..59e5402b7 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -56,7 +56,13 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); - for (frame, values) in rows { + for (frame, mut values) in rows { + // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. + if !layer.is_3d() + && let Some(z) = values.get_mut(2) + { + *z = if *id == "scale" { 100.0 } else { 0.0 }; + } let values: Vec = values.into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 81ca97bd4..8ff9f9c20 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -138,6 +138,26 @@ fn properties_the_layer_does_not_use_are_left_out() { assert!(names(&text).contains(&"Point of Interest"), "{text}"); } +#[test] +fn a_2d_layer_writes_the_z_it_shows() { + // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. + let (mut s, l) = session(); + let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + edit_layer(&mut s, src, |l| l.switches.three_d = true); + add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300])); + add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70])); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap(); + let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components(); + assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z"); + s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t0\t100\t200\t0\n"), "{text}"); + assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\n"), "{text}"); +} + #[test] fn keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 47093d997ada42049f7bf79710f430de6b80acc0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:52:55 -0400 Subject: [PATCH 0236/1386] Clipboard: keyframe text has the source's pixel aspect (#1908) --- crates/engine/src/keyframe_text.rs | 16 ++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 59e5402b7..7cb8dc492 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! (frame number in comp time, then the value) and static ones as a single row with no frame. //! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{AutoOrient, Comp, Layer, LayerId, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Uid}; use crate::KeyRef; @@ -71,13 +71,25 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( if blocks.is_empty() { return None; } + // Layers without a source of their own (text, shapes, cameras) take the comp's size and + // pixel aspect. let (w, h) = match effectcraft_render::eval::source_size(project, layer) { (0, 0) => (comp.width, comp.height), size => size, }; + let par = match layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.pixel_aspect, + Some(ItemKind::Solid(s)) => s.pixel_aspect, + Some(ItemKind::Comp(c)) => c.pixel_aspect, + _ => comp.pixel_aspect, + }, + _ => comp.pixel_aspect, + }; Some(format!( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", num(fps), + num(par), num(comp.pixel_aspect) )) } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 8ff9f9c20..e51c241fe 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -158,6 +158,19 @@ fn a_2d_layer_writes_the_z_it_shows() { assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\n"), "{text}"); } +#[test] +fn the_header_has_the_source_pixel_aspect() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap(); + let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, solid); + assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A text layer has no source of its own: the comp's. + let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, t); + assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); +} + #[test] fn keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 8ace9aac7f8b09e3862bdf047fe355023f2f973e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:10:29 -0400 Subject: [PATCH 0237/1386] Clipboard: keyframe text starts the layer with a Layer line (#1908) --- crates/engine/src/keyframe_text.rs | 4 +++- crates/engine/src/tests_keyframe_text.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 7cb8dc492..21727bc1c 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -86,8 +86,10 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( }, _ => comp.pixel_aspect, }; + // After Effects starts the layer's properties with a `Layer` line; parsers such as + // ae-keyframes reject properties without one. Some(format!( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}End of Keyframe Data\n", num(fps), num(par), num(comp.pixel_aspect) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index e51c241fe..968482f60 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -51,6 +51,7 @@ fn copied_keys_are_after_effects_text_in_comp_frames() { s.state.keyframe_text.as_deref(), Some( "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Layer\n\ Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\ End of Keyframe Data\n" From a33011047669688bce56eac117a1d2930496ee1d Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 16:12:22 +0300 Subject: [PATCH 0238/1386] G5: a styled layer rendered by two workers at once shares its body and passes (#1812) Review: styled() kept the buffers it rendered instead of the ones LayerCache::insert returned, so without a blending-space conversion concurrent misses still returned separate body and pass buffers (and the GPU uploaded each). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 11 ++++++----- crates/render/src/tests_styles.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3876cb7ae..73b86e6b6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1589,12 +1589,13 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - let body = Arc::new(st.body); - let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + let mut body = Arc::new(st.body); + let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + // The buffers the cache keeps (another worker's, if it kept them first). if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, body.clone()); - for (i, (b, _)) in passes.iter().enumerate() { - c.insert(cache::derive(k, i as u64 + 1), b.clone()); + body = c.insert(k, body); + for (i, (b, _)) in passes.iter_mut().enumerate() { + *b = c.insert(cache::derive(k, i as u64 + 1), b.clone()); } } Some(StyledLayer { passes, body, content, key, ckey, plain: false }) diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs index 147ff1590..b59f009de 100644 --- a/crates/render/src/tests_styles.rs +++ b/crates/render/src/tests_styles.rs @@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() { let px = img.straight(121, 50); assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}"); } + +/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and +/// style passes, as unstyled layers share their buffer. +#[test] +fn workers_styling_a_layer_at_once_share_its_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + add(p, c, l, "stroke"); + }); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let styled: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + let first = &styled[0]; + assert!(!first.passes.is_empty(), "the drop shadow is a pass"); + for st in &styled { + assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body"); + assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); + } +} From 49ec37ec3f1d5a1844b628652fecc63f76d6cbe5 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 16:40:24 +0300 Subject: [PATCH 0239/1386] Disk cache: an edit that leaves the footage alone keeps its salt (#1926) Session::bump took the disk cache's footage salt at every edit, which reads the size and time of every footage file, each frame of an image sequence included. With three 5000-frame sequences a caret move or a typed character in a text field took ~0.6 s on the UI thread (9 ms with the disk cache off). The salt is now taken again only when the footage items changed, or when a project is opened, footage is reloaded or the disk cache attached. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/footage_reload.rs | 2 ++ crates/engine/src/lib.rs | 41 +++++++++++++++++++++++++-- crates/engine/src/session_settings.rs | 6 ++-- crates/engine/src/tests_sequence.rs | 28 ++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index fa12b27aa..4d22ce0ef 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -193,6 +193,8 @@ fn forget(s: &mut Session, paths: &BTreeSet) { s.footage.forget(p); } s.layer_cache.clear(); + // Reloaded files may keep their footage settings but not their size and time. + s.refresh_disk_salt(true); s.events.push(crate::Event::PurgeCaches); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..ff40d5e6c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -521,6 +521,8 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// The disk cache's footage salt and the footage it was taken for ([`Session::refresh_disk_salt`]). + pub disk_salt: Option<(u64, u64)>, /// How many script-issued commands are running ([`Session::execute_for_script`]): while one /// is, settings that guard what scripts may do can't change (see /// [`prefs::Scripting::same_permissions`]). @@ -618,6 +620,7 @@ impl Default for Session { check_footage_on_open: false, stored_paths: Default::default(), footage_stamps: Default::default(), + disk_salt: None, footage_scan: None, script_calls: ScriptCalls::default(), } @@ -736,12 +739,42 @@ impl Session { if Arc::ptr_eq(&self.project, &self.saved_project) { self.saved_revision = self.revision; } - if self.disk_cache.is_some() { - self.layer_cache.set_disk_salt(effectcraft_render::disk_cache::footage_salt(&self.project)); - } + self.refresh_disk_salt(false); self.events.push(Event::ProjectChanged { revision: self.revision }); } + /// Salt the disk cache's layer keys with the project's footage files + /// ([`effectcraft_render::disk_cache::footage_salt`]). Taking it reads the size and time of + /// every file, every frame of an image sequence included, so it is taken again only when the + /// footage items changed, or with `force` (a project opened, footage reloaded, the disk cache + /// attached), not at every edit: with a few large sequences that cost each keystroke in a + /// text field over half a second (#1926). + pub fn refresh_disk_salt(&mut self, force: bool) -> Option { + use std::hash::{Hash, Hasher}; + if self.disk_cache.is_none() { + self.disk_salt = None; + return None; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + for (id, it) in &self.project.items { + if let effectcraft_project::ItemKind::Footage(f) = &it.kind { + id.hash(&mut h); + format!("{f:?}").hash(&mut h); + } + } + let footage = h.finish(); + if let Some((seen, salt)) = self.disk_salt + && seen == footage + && !force + { + return Some(salt); + } + let salt = effectcraft_render::disk_cache::footage_salt(&self.project); + self.disk_salt = Some((footage, salt)); + self.layer_cache.set_disk_salt(salt); + Some(salt) + } + /// Set a comp's Resolution. As in After Effects it is saved with the project (which becomes /// modified) but is no undo step, and it changes no pixels: the comp's cached frames stay /// valid ([`effectcraft_project::Comp::same_pixels`]). @@ -1169,6 +1202,8 @@ impl Session { } self.layer_cache.clear(); self.project = Arc::new(p); + // Its files may have changed since they were last stamped. + self.disk_salt = None; self.history = History::default(); self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() }; self.path = path; diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ef3e748a6..209dfeb6e 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -102,7 +102,7 @@ impl Session { /// and attach it to the layer cache. Without a folder setting the platform cache folder is /// used, but only by frontends with a settings store (headless sessions stay off disk). pub fn configure_disk_cache(&mut self) { - use effectcraft_render::disk_cache::{DiskCache, default_folder, footage_salt}; + use effectcraft_render::disk_cache::{DiskCache, default_folder}; let d = &self.prefs.disk; let folder = if d.disk_cache_folder.trim().is_empty() { (self.config.is_some() && !cfg!(target_arch = "wasm32")).then(default_folder) @@ -131,7 +131,9 @@ impl Session { } }, }; - self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project)))); + self.disk_cache = Some(dc.clone()); + let salt = self.refresh_disk_salt(true).unwrap_or_default(); + self.layer_cache.set_disk(Some((dc.clone(), salt))); // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the // disk cache off drops it). let store: std::sync::Arc = dc.clone(); diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index 7f937596f..018e4327e 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -207,3 +207,31 @@ fn dpx_and_cineon_runs_import_as_sequences() { got.sort(); assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}"); } + +/// #1926: an edit that leaves the footage alone doesn't stamp every frame of its image sequences +/// again for the disk cache's salt (with large sequences each keystroke in a text field took over +/// half a second); Reload Footage does, and so does new footage. +#[test] +fn edits_keep_the_disk_salt_and_reload_stamps_the_files_again() { + let dir = folder(&RUN); + let cache = std::env::temp_dir().join(format!("ec-seq-salt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&cache); + let mut s = session(); + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": cache.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true, "addToComp": true})).unwrap(); + let item = r["items"][0].clone(); + let salt = s.refresh_disk_salt(false).expect("a disk cache"); + // A frame rewritten in place: same footage settings, another size on disk. + std::fs::write(at(&dir, "shot_0004.png"), b"a new render of this frame").unwrap(); + s.execute("layer.newText", json!({"text": "title"})).unwrap(); + s.execute("layer.setText", json!({"text": "title 2"})).unwrap(); + assert_eq!(s.refresh_disk_salt(false), Some(salt), "edits don't stamp the files again"); + s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); + let reloaded = s.refresh_disk_salt(false).unwrap(); + assert_ne!(reloaded, salt, "Reload Footage stamps them again"); + std::fs::write(at(&dir, "extra.png"), b"").unwrap(); + s.execute("file.import", json!({"paths": [at(&dir, "extra.png")]})).unwrap(); + assert_ne!(s.refresh_disk_salt(false), Some(reloaded), "new footage counts"); + let _ = std::fs::remove_dir_all(&cache); +} From b24438f9bbd5d05f96a35913681bf7fd76647d32 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:40:59 -0400 Subject: [PATCH 0240/1386] CLI: template renders keep the template composition's name (#1937) --- apps/effectcraft-cli/src/template.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index 844c978b2..8cf5c124f 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map, import } 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()))?; From 30dc57f790e9e55f461662e305923b0709a79239 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:42:08 -0400 Subject: [PATCH 0241/1386] Clipboard: keyframe text in current After Effects' layout, with a tab after each column (#1908) --- crates/engine/src/keyframe_text.rs | 10 +++++----- crates/engine/src/tests_keyframe_text.rs | 19 ++++++++++--------- crates/ui-egui/tests/ui_keyframes.rs | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 21727bc1c..0c090e5c4 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -55,7 +55,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( _ if *id == "orientation" => vec![format!("X {unit}")], _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; - blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); + blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\t\n", pr.name, columns.join("\t"))); for (frame, mut values) in rows { // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. if !layer.is_3d() @@ -64,7 +64,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( *z = if *id == "scale" { 100.0 } else { 0.0 }; } let values: Vec = values.into_iter().map(num).collect(); - blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); + blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); } blocks.push('\n'); } @@ -86,10 +86,10 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( }, _ => comp.pixel_aspect, }; - // After Effects starts the layer's properties with a `Layer` line; parsers such as - // ae-keyframes reject properties without one. + // The layout of current After Effects: a `Layer` line before the layer's properties (parsers + // such as ae-keyframes reject properties without one), and a tab after every column. Some(format!( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}End of Keyframe Data\n", + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n", num(fps), num(par), num(comp.pixel_aspect) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 968482f60..ef9ad509c 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -50,10 +50,11 @@ fn copied_keys_are_after_effects_text_in_comp_frames() { assert_eq!( s.state.keyframe_text.as_deref(), Some( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + "Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ Layer\n\ - Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ - Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\ + Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\ + Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\ + \n\ End of Keyframe Data\n" ) ); @@ -72,7 +73,7 @@ fn a_stretched_layer_writes_the_frames_its_keys_show_at() { s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); - assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t0\t0\n\t12\t100\n\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}"); } #[test] @@ -82,7 +83,7 @@ fn copied_static_values_have_no_frame() { s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); - assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t\t100\t200\t0\n\n"), "{text}"); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}"); assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value"); } @@ -98,7 +99,7 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { let text = s.state.keyframe_text.clone().unwrap(); let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); - assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); + assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}"); // 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three // values sit under one label, as After Effects writes them. edit_layer(&mut s, l, |l| l.switches.three_d = true); @@ -107,7 +108,7 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { let text = s.state.keyframe_text.clone().unwrap(); let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]); - assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\n\t\t0\t0\t0\n"), "{text}"); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}"); } fn copy_transform(s: &mut Session, lid: LayerId) -> String { @@ -155,8 +156,8 @@ fn a_2d_layer_writes_the_z_it_shows() { s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec(); s.execute("keys.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); - assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t0\t100\t200\t0\n"), "{text}"); - assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\n"), "{text}"); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}"); + assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}"); } #[test] diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 2eebb068f..b137a06fc 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -108,8 +108,8 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { // The system clipboard has the key as After Effects keyframe text (for other applications), // so it isn't empty and Ctrl+V sends a paste event. let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; - assert!(text.starts_with("Adobe After Effects 8.0 Keyframe Data\n"), "{text}"); - assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t30\t100\n"), "{text}"); + assert!(text.starts_with("Adobe After Effects 9.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t30\t100\t\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); From 9ee6c5ef62351a25370477ed6dd525dc37e5f840 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:45:51 -0400 Subject: [PATCH 0242/1386] Clipboard: keyframe text for Time Remap and separated X / Y / Z Position (#1908) --- crates/engine/src/keyframe_text.rs | 132 ++++++++++++++++------- crates/engine/src/tests_keyframe_text.rs | 35 ++++++ 2 files changed, 126 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 0c090e5c4..9703fa503 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -1,28 +1,57 @@ //! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other //! applications read this tab-separated format (AE2Blend brings it into Blender). Like After -//! Effects it covers one layer: its Transform properties, keyframed ones as one row per key -//! (frame number in comp time, then the value) and static ones as a single row with no frame. -//! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. +//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame +//! number in comp time, then the value) and static ones as a single row with no frame. Easing +//! and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid}; use crate::KeyRef; -/// Transform properties in After Effects' order, with the unit each column is labelled with. -const CHANNELS: &[(&str, &str)] = &[ - ("poi", "pixels"), - ("anchor", "pixels"), - ("position", "pixels"), - ("scale", "percent"), - ("orientation", "degrees"), - ("rotationX", "degrees"), - ("rotationY", "degrees"), - ("rotation", "degrees"), - ("opacity", "percent"), +/// Transform properties with the unit their columns are labelled with and, for vectors, the +/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit. +const TRANSFORM: &[(&str, &str, Option)] = &[ + ("poi", "pixels", Some(0.0)), + ("anchor", "pixels", Some(0.0)), + ("position", "pixels", Some(0.0)), + ("positionX", "", None), + ("positionY", "", None), + ("positionZ", "", None), + ("scale", "percent", Some(100.0)), + ("orientation", "degrees", None), + ("rotationX", "degrees", None), + ("rotationY", "degrees", None), + ("rotation", "degrees", None), + ("opacity", "percent", None), ]; +/// How a copied property is written: its block's header line, column labels and values. +struct Channel { + header: String, + labels: Vec, + values: Values, +} + +/// How a property's value becomes the numbers in a row. +#[derive(Clone, Copy)] +enum Values { + Plain, + /// A 2D layer's Transform vector: the third component is the one the layer shows (it may + /// keep another from pasted 3D keys). + Flat(f64), +} + +impl Values { + fn row(self, mut v: Vec) -> Vec { + if let (Values::Flat(z), Some(c)) = (self, v.get_mut(2)) { + *c = z; + } + v + } +} + /// The text for copied keys (layer times) and unanimated properties. `None` unless they all come -/// from one layer and at least one is a Transform property. +/// from one layer and at least one property can be written. pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { @@ -31,11 +60,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( let layer = comp.layer(lid)?; let fps = comp.frame_rate.as_f64(); let mut blocks = String::new(); - for (id, unit) in CHANNELS { - let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; - if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { - continue; - } + layer.props.walk("", &mut |_, pr| { let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); times.sort(); times.dedup(); @@ -45,29 +70,19 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( } else if statics.iter().any(|s| s.1 == pr.uid) { vec![(String::new(), pr.value.components())] } else { - continue; + return; }; - let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; - // After Effects labels only the first of Orientation's three columns, and names Z - // Rotation plain Rotation (as AE2Blend expects). - let columns = match dims { - 1 => vec![unit.to_string()], - _ if *id == "orientation" => vec![format!("X {unit}")], - _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), - }; - blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\t\n", pr.name, columns.join("\t"))); - for (frame, mut values) in rows { - // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. - if !layer.is_3d() - && let Some(z) = values.get_mut(2) - { - *z = if *id == "scale" { 100.0 } else { 0.0 }; - } - let values: Vec = values.into_iter().map(num).collect(); + let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return }; + if rows.iter().any(|(_, v)| v.is_empty()) { + return; + } + blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t"))); + for (frame, values) in rows { + let values: Vec = ch.values.row(values).into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); } blocks.push('\n'); - } + }); if blocks.is_empty() { return None; } @@ -96,9 +111,44 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( )) } +/// How the layer's property `pr` is written, by where it sits in the layer: `None` for +/// properties the text doesn't carry. +fn channel(layer: &Layer, pr: &Property) -> Option { + let chain = layer.props.node_chain(pr.uid)?; + match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) { + None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), + Some("transform") => transform(layer, pr), + _ => None, + } +} + +fn transform(layer: &Layer, pr: &Property) -> Option { + let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { + return None; + } + // After Effects labels only the first of Orientation's three columns, and names Z Rotation + // plain Rotation (as AE2Blend expects). + let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) }; + let values = match z { + Some(z) if !layer.is_3d() => Values::Flat(*z), + _ => Values::Plain, + }; + Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) +} + +/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`). +fn labels(unit: &str, dims: usize) -> Vec { + match dims { + 0 => vec![], + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}").trim_end().to_string()).collect(), + } +} + /// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at /// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z -/// Position (which this text doesn't cover). +/// Position. fn active(layer: &Layer, id: &str) -> bool { match id { "poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest, diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index ef9ad509c..bc441fcf3 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -140,6 +140,41 @@ fn properties_the_layer_does_not_use_are_left_out() { assert!(names(&text).contains(&"Point of Interest"), "{text}"); } +#[test] +fn separated_position_writes_x_y_and_z_position() { + let (mut s, l) = session(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100)); + add_key(&mut s, l, "transform/positionX", t, json!(321)); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap(); + let text = copy_transform(&mut s, l); + // After Effects labels their single column with no unit. + assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}"); + assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_transform(&mut s, l); + assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}"); +} + +#[test] +fn time_remap_writes_source_seconds_at_comp_frames() { + let (mut s, l) = session(); + let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap(); + let l = LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layers": [l.0]})).unwrap(); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + let t = frame(&s, 6); + add_key(&mut s, l, "timeRemap", t, json!(2.5)); + s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + // As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16. + assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}"); +} + #[test] fn a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 39142e9e6bcf6681c48c77085bf4bbea055aa3af Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:49:25 -0400 Subject: [PATCH 0243/1386] Clipboard: keyframe text for effect properties (#1908) --- crates/engine/src/keyframe_text.rs | 51 ++++++++++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 41 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 9703fa503..5d896327b 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! number in comp time, then the value) and static ones as a single row with no frame. Easing //! and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid}; +use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; use crate::KeyRef; @@ -39,14 +39,25 @@ enum Values { /// A 2D layer's Transform vector: the third component is the one the layer shows (it may /// keep another from pasted 3D keys). Flat(f64), + /// RGBA 0–1 as alpha, red, green, blue 0–255. + Argb, + /// A popup's 0-based choice as After Effects' 1-based one. + OneBased, } impl Values { fn row(self, mut v: Vec) -> Vec { - if let (Values::Flat(z), Some(c)) = (self, v.get_mut(2)) { - *c = z; + match (self, v.as_slice()) { + (Values::Flat(z), _) => { + if let Some(c) = v.get_mut(2) { + *c = z; + } + v + } + (Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(), + (Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(), + _ => v, } - v } } @@ -118,10 +129,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option { match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) { None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), Some("transform") => transform(layer, pr), + Some("effects") => effect(&chain, pr), _ => None, } } +/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, +/// then the property's name and place, counting the effect's hidden input layer as the first. +fn effect(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(effects), Node::Group(fx), _] = chain else { return None }; + let GroupKind::Effect { effect } = &fx.kind else { return None }; + let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); + let (labels, values) = by_ui(pr)?; + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1); + Some(Channel { header, labels, values }) +} + +/// 1-based position of a child in its group. +fn place(group: &PropGroup, uid: Uid) -> Option { + group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) +} + +/// Column labels and values for an effect or text property, by how it's edited (After Effects +/// labels sliders, checkboxes and popups with no unit). +fn by_ui(pr: &Property) -> Option<(Vec, Values)> { + let dims = pr.value.dims(); + Some(match &pr.ui { + ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), + ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), + ParamUi::Percent => (labels("percent", dims), Values::Plain), + ParamUi::Angle => (labels("degrees", dims), Values::Plain), + ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), + ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain), + _ => return None, + }) +} + fn transform(layer: &Layer, pr: &Property) -> Option { let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index bc441fcf3..6079ec60a 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -175,6 +175,47 @@ fn time_remap_writes_source_seconds_at_comp_frames() { assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}"); } +/// Selects the layer's properties at `paths` and copies them. +fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { + for (i, path) in paths.iter().enumerate() { + s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap(); + } + s.execute("edit.copy", json!({})).unwrap(); + s.state.keyframe_text.clone().unwrap() +} + +#[test] +fn effect_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same effects and values. + let (mut s, l) = session(); + for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let t = frame(&s, 12); + add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1])); + add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1])); + add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true)); + add_key(&mut s, l, "effects/#2/checkbox", t, json!(false)); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap(); + s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap(); + // After Effects writes a renamed effect under the effect's own name. + let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap(); + let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]); + assert!( + text.contains( + "Layer\n\ + Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\ + Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\ + Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\ + Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\ + Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n" + ), + "{text}" + ); +} + #[test] fn a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 6cad56b19d91b2533c5cb1a710ddee5821c7fd04 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:53:28 -0400 Subject: [PATCH 0244/1386] Clipboard: keyframe text for mask properties (#1908) --- crates/engine/src/keyframe_text.rs | 14 +++++++++++ crates/engine/src/tests_keyframe_text.rs | 30 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 5d896327b..a5a596ba5 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -130,10 +130,24 @@ fn channel(layer: &Layer, pr: &Property) -> Option { None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), + Some("masks") => mask(&chain, pr), _ => None, } } +/// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a +/// number, and After Effects leaves it out. +fn mask(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(masks), Node::Group(m), _] = chain else { return None }; + let unit = match pr.match_id.as_str() { + "feather" | "expansion" => "pixels", + "opacity" => "percent", + _ => return None, + }; + let header = format!("Masks\tMask #{}\t{}", place(masks, m.uid)?, pr.name); + Some(Channel { header, labels: labels(unit, pr.value.dims()), values: Values::Plain }) +} + /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, /// then the property's name and place, counting the effect's hidden input layer as the first. fn effect(chain: &[&Node], pr: &Property) -> Option { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 6079ec60a..8fc09caa9 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -216,6 +216,36 @@ fn effect_properties_are_written_as_after_effects_does() { ); } +#[test] +fn mask_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not + // name, and Mask Path is left out. + let (mut s, l) = session(); + for _ in 0..2 { + s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap(); + } + let t = frame(&s, 12); + for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] { + add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a); + add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b); + } + s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap(); + let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]); + assert!( + text.contains( + "Layer\n\ + Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\ + Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\ + Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\ + Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\ + \nEnd of Keyframe Data\n" + ), + "{text}" + ); +} + #[test] fn a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 5fb5666d78c3fb66983dafed6efccd0f3f271ea4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:56:25 -0400 Subject: [PATCH 0245/1386] Tests: template render outputs keep the template composition's name (#1937) --- apps/effectcraft-cli/tests/qa_template.rs | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index 8b877755b..0618c0d63 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -767,3 +767,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() { 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); +} From 05f2daa4caaead4578cdcf0602df7b5d0bf8eb70 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:57:40 -0400 Subject: [PATCH 0246/1386] Clipboard: keyframe text for text layer properties, and percent sliders labelled percent (#1908) --- crates/engine/src/keyframe_text.rs | 38 +++++++++++++++- crates/engine/src/tests_keyframe_text.rs | 58 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index a5a596ba5..6fa625fb5 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -131,10 +131,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option { Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), Some("masks") => mask(&chain, pr), + Some("text") => text_prop(&chain, pr), _ => None, } } +/// Text animators and selectors by match id, with the kind After Effects names them by. +const TEXT_INSTANCES: &[(&str, &str)] = + &[("animator", "Animator"), ("rangeSelector", "Range Selector"), ("wigglySelector", "Wiggly Selector"), ("expressionSelector", "Expression Selector")]; + +/// A text property: `Text`, then each group down to the property. Animators and selectors go by +/// kind and place (`Animator #1`), not name, and a selector's own properties carry their place +/// too (`Start #1`). Source Text isn't a number, and After Effects leaves it out. +fn text_prop(chain: &[&Node], pr: &Property) -> Option { + let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); + let parent = groups.last()?; + // Path Options' Path picks a mask: not a value the text carries. + if parent.match_id == "pathOptions" && pr.match_id == "path" { + return None; + } + let (labels, values) = by_ui(pr)?; + let mut header = String::from("Text"); + for pair in groups.windows(2) { + let [outer, g] = pair else { continue }; + match TEXT_INSTANCES.iter().find(|(id, _)| *id == g.match_id) { + Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)), + None => header.push_str(&format!("\t{}", g.name)), + } + } + if parent.match_id.ends_with("Selector") { + header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?)); + } else { + header.push_str(&format!("\t{}", pr.name)); + } + Some(Channel { header, labels, values }) +} + /// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a /// number, and After Effects leaves it out. fn mask(chain: &[&Node], pr: &Property) -> Option { @@ -164,13 +196,15 @@ fn place(group: &PropGroup, uid: Uid) -> Option { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) } -/// Column labels and values for an effect or text property, by how it's edited (After Effects -/// labels sliders, checkboxes and popups with no unit). +/// Column labels and values for an effect or text property, by how it's edited. After Effects +/// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other +/// sliders, checkboxes and popups with no unit. fn by_ui(pr: &Property) -> Option<(Vec, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), + ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || *min == -100.0) => (labels("percent", dims), Values::Plain), ParamUi::Percent => (labels("percent", dims), Values::Plain), ParamUi::Angle => (labels("degrees", dims), Values::Plain), ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 8fc09caa9..d1f9ddb9b 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -246,6 +246,64 @@ fn mask_properties_are_written_as_after_effects_does() { ); } +#[test] +fn text_properties_are_written_as_after_effects_does() { + // The blocks After Effects 2019 wrote for the same text properties and values. + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap(); + let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap(); + s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap(); + let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid; + s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello")); + add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0)); + add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100)); + add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0])); + add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0])); + for (path, v) in [ + ("text/animators/#1/selectors/#1/end", json!(80)), + ("text/animators/#1/selectors/#1/offset", json!(-10)), + ("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])), + ("text/moreOptions/groupingAlignment", json!([10, -20])), + ] { + s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &[ + "text/sourceText", + "text/pathOptions/firstMargin", + "text/animators/#1/selectors/#1/start", + "text/animators/#1/selectors/#1/end", + "text/animators/#1/selectors/#1/offset", + "text/animators/#1/selectors/#1/advanced/amount", + "text/animators/#1/properties/position", + "text/animators/#1/properties/fillColor", + "text/animators/#2/properties/opacity", + "text/moreOptions/groupingAlignment", + ], + ); + for block in [ + "Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n", + "Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n", + "Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n", + "Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n", + ] { + assert!(text.contains(block), "{block}\nin\n{text}"); + } + assert!(!text.contains("Source Text"), "{text}"); +} + #[test] fn a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 7bbdfb41f6c0f07f9b455e8f8f241d44b0fa377b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:58:52 +0200 Subject: [PATCH 0247/1386] Return identity from Mat3::square_to_quad for a degenerate quad Found while testing crates/geom/src/lib.rs:252-264 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 3 +++ crates/geom/tests/square_to_quad.rs | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 crates/geom/tests/square_to_quad.rs diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index a03c9d46c..cbc9c1072 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -261,6 +261,9 @@ impl Mat3 { return Mat3([[x1 - x0, x3 - x0, x0], [y1 - y0, y3 - y0, y0], [0.0, 0.0, 1.0]]); } let den = dx1 * dy2 - dx2 * dy1; + if den == 0.0 || !den.is_finite() { + return Mat3::IDENTITY; + } let g = (sx * dy2 - dx2 * sy) / den; let h = (dx1 * sy - sx * dy1) / den; Mat3([[x1 - x0 + g * x1, x3 - x0 + h * x3, x0], [y1 - y0 + g * y1, y3 - y0 + h * y3, y0], [g, h, 1.0]]) diff --git a/crates/geom/tests/square_to_quad.rs b/crates/geom/tests/square_to_quad.rs new file mode 100644 index 000000000..3dd409a58 --- /dev/null +++ b/crates/geom/tests/square_to_quad.rs @@ -0,0 +1,13 @@ +use effectcraft_geom::{Mat3, vec2}; + +#[test] +fn square_to_quad_degenerate_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 5.0), vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(20.0, 0.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite()), "non-finite: {m:?}"); +} + +#[test] +fn square_to_quad_regular_is_finite() { + let m = Mat3::square_to_quad([vec2(0.0, 0.0), vec2(10.0, 0.0), vec2(8.0, 6.0), vec2(0.0, 5.0)]); + assert!(m.0.iter().flatten().all(|v| v.is_finite())); +} From dac6421ec353f4c8ba7e587d1ec03bcbfe443f8f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:23:52 -0400 Subject: [PATCH 0248/1386] Clipboard: effect properties in twirl-down groups, numbered as After Effects does (#1908) --- crates/engine/src/keyframe_text.rs | 40 ++++++++++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 24 +++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 6fa625fb5..59367b19b 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -181,16 +181,50 @@ fn mask(chain: &[&Node], pr: &Property) -> Option { } /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, -/// then the property's name and place, counting the effect's hidden input layer as the first. +/// then the property's name and After Effects index. Properties in twirl-down groups are +/// written without their group, as After Effects does. fn effect(chain: &[&Node], pr: &Property) -> Option { - let [Node::Group(effects), Node::Group(fx), _] = chain else { return None }; + let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); let (labels, values) = by_ui(pr)?; - let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1); + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } +/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a +/// twirl-down group takes an index where it opens and another where it closes. The hidden +/// source choice EffectCraft keeps beside a layer parameter has none. +fn effect_index(fx: &PropGroup, uid: Uid) -> Option { + fn count(g: &PropGroup, uid: Uid, n: &mut usize) -> bool { + for c in &g.children { + match c { + Node::Prop(p) if is_layer_source(g, p) => {} + Node::Prop(p) => { + *n += 1; + if p.uid == uid { + return true; + } + } + Node::Group(sub) => { + *n += 1; + if count(sub, uid, n) { + return true; + } + *n += 1; + } + } + } + false + } + let mut n = 1; + count(fx, uid, &mut n).then_some(n) +} + +fn is_layer_source(g: &PropGroup, p: &Property) -> bool { + p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id) +} + /// 1-based position of a child in its group. fn place(group: &PropGroup, uid: Uid) -> Option { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index d1f9ddb9b..dfdbe23a1 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -186,7 +186,8 @@ fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { #[test] fn effect_properties_are_written_as_after_effects_does() { - // The blocks After Effects 2019 wrote for the same effects and values. + // The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown + // Menu Control: that block follows its other popups, written 1-based with no unit). let (mut s, l) = session(); for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] { s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); @@ -216,6 +217,27 @@ fn effect_properties_are_written_as_after_effects_does() { ); } +#[test] +fn effect_properties_have_after_effects_indices() { + // After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9, + // Complexity (after it) #17 and Evolution #25: a group takes an index where it opens and + // closes, and its properties are written without it. Set Matte's Use For Matte is #3: the + // layer parameter before it has no extra index for its source choice. + let (mut s, l) = session(); + for fx in ["Fractal Noise", "Set Matte"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props(&mut s, l, &["effects/#1/transform/rotation", "effects/#1/complexity", "effects/#1/evolution", "effects/#2/takeMatteFrom"]); + for header in [ + "Effects\tFractal Noise #1\tRotation #9\n", + "Effects\tFractal Noise #1\tComplexity #17\n", + "Effects\tFractal Noise #1\tEvolution #25\n", + "Effects\tSet Matte #2\tUse For Matte #3\n", + ] { + assert!(text.contains(header), "{header}in\n{text}"); + } +} + #[test] fn mask_properties_are_written_as_after_effects_does() { // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not From b40c1aff227441fc1526056ba7b0eacbfcc1009a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:26:10 -0400 Subject: [PATCH 0249/1386] Clipboard: keyframe text gives a text layer's source as 1 x 1, as After Effects does (#1908) --- crates/engine/src/keyframe_text.rs | 11 ++++++----- crates/engine/src/tests_keyframe_text.rs | 8 ++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 59367b19b..851b94ee3 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -97,11 +97,12 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( if blocks.is_empty() { return None; } - // Layers without a source of their own (text, shapes, cameras) take the comp's size and - // pixel aspect. - let (w, h) = match effectcraft_render::eval::source_size(project, layer) { - (0, 0) => (comp.width, comp.height), - size => size, + // After Effects writes a text layer's source as 1 × 1. Other layers without a source of + // their own (shapes, cameras) take the comp's size, and all of them its pixel aspect. + let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) { + (LayerSource::Text, _) => (1, 1), + (_, (0, 0)) => (comp.width, comp.height), + (_, size) => size, }; let par = match layer.source { LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index dfdbe23a1..0ca688466 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -353,10 +353,14 @@ fn the_header_has_the_source_pixel_aspect() { let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap()); let text = copy_transform(&mut s, solid); assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}"); - // A text layer has no source of its own: the comp's. + // A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect. let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap()); let text = copy_transform(&mut s, t); - assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); + assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); + // A shape layer has no source of its own: the comp's size. + let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_transform(&mut s, shape); + assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}"); } #[test] From c5f028b5cd7cba79b5877b4c5b6acbfa33d3053c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:36:14 -0400 Subject: [PATCH 0250/1386] Clipboard: test Fractal Noise's indices after Evolution Options (#1908) --- crates/engine/src/tests_keyframe_text.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 0ca688466..d40e79e67 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -220,18 +220,32 @@ fn effect_properties_are_written_as_after_effects_does() { #[test] fn effect_properties_have_after_effects_indices() { // After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9, - // Complexity (after it) #17 and Evolution #25: a group takes an index where it opens and - // closes, and its properties are written without it. Set Matte's Use For Matte is #3: the - // layer parameter before it has no extra index for its source choice. + // Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a + // group takes an index where it opens and closes, and its properties are written without + // it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for + // its source choice. let (mut s, l) = session(); for fx in ["Fractal Noise", "Set Matte"] { s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); } - let text = copy_props(&mut s, l, &["effects/#1/transform/rotation", "effects/#1/complexity", "effects/#1/evolution", "effects/#2/takeMatteFrom"]); + let text = copy_props( + &mut s, + l, + &[ + "effects/#1/transform/rotation", + "effects/#1/complexity", + "effects/#1/evolution", + "effects/#1/opacity", + "effects/#1/blendingMode", + "effects/#2/takeMatteFrom", + ], + ); for header in [ "Effects\tFractal Noise #1\tRotation #9\n", "Effects\tFractal Noise #1\tComplexity #17\n", "Effects\tFractal Noise #1\tEvolution #25\n", + "Effects\tFractal Noise #1\tOpacity #31\n", + "Effects\tFractal Noise #1\tBlending Mode #32\n", "Effects\tSet Matte #2\tUse For Matte #3\n", ] { assert!(text.contains(header), "{header}in\n{text}"); From a67ca11d1ca1cac70108b680543f093b1ea3b366 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Tue, 20 Oct 2026 20:23:41 +0530 Subject: [PATCH 0251/1386] script: setValue rejects an array of the wrong dimension (#1936) A 2-D point property took any array: `[1, 2, 3]` and `[1, 2, 3, 4]` were cut to `[1, 2]` and `[1]` was broadcast to `[1, 1]`, each reported as success, so a script holding a wrong value silently did something else instead of failing. After Effects raises "Array is not of the correct dimension" and leaves the property unchanged. The length must now match the property's dimension, with the one exception After Effects also makes: two values for a 3-D property, where z stays 0, so `position.setValue([x, y])` keeps working. Co-Authored-By: Claude Opus 5 --- crates/script/src/prelude.js | 5 ++++- crates/script/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2eeca86bf..52712bc06 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -869,8 +869,11 @@ Property.prototype.property = function () { return null; }; } if (info.dims > 1) { if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers"); + // After Effects rejects an array of the wrong length instead of truncating or broadcasting it + // (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0. + if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension"); var o = []; - for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1]))); + for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0); return o; } if (v instanceof Array) throw __err("value must be a number, not an array"); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6f87f7572..a1e961940 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1585,3 +1585,41 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() { let (_, c) = comp_named(&s, "x"); assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0)); } + +/// #1936: an array whose length doesn't match the property's dimension is an error in After +/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. +#[test] +fn set_value_rejects_an_array_of_the_wrong_dimension() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30); + var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1); + var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001"); + function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } } + var before = "" + ramp.value; + var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); }); + var tooFew = attempt(function () { ramp.setValue([1]); }); + var untouched = "" + ramp.value; + ramp.setValue([7, 8]); + var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); }); + // A 3-D property still takes two values, as it does in After Effects: z stays 0. + l.property("Position").setValue([120, 34]); + [tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value] + "#, + ); + assert_eq!( + o.result, + json!([ + "Array is not of the correct dimension", + "Array is not of the correct dimension", + true, + "7,8", + "Array is not of the correct dimension", + "120,34,0" + ]) + ); + let (_, c) = comp_named(&s, "Dims"); + assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0])); +} From f9d6c0801be17f0ec029e130b546417daa20e387 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:57:52 -0400 Subject: [PATCH 0252/1386] Clipboard: keyframe text for shape contents and camera, light and material options (#1908) --- crates/engine/src/keyframe_text.rs | 114 ++++++++++++++++------- crates/engine/src/tests_keyframe_text.rs | 59 ++++++++++++ 2 files changed, 141 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 851b94ee3..e57909b15 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -43,6 +43,7 @@ enum Values { Argb, /// A popup's 0-based choice as After Effects' 1-based one. OneBased, + Scale(f64), } impl Values { @@ -56,6 +57,7 @@ impl Values { } (Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(), (Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(), + (Values::Scale(s), _) => v.into_iter().map(|c| c * s).collect(), _ => v, } } @@ -131,31 +133,50 @@ fn channel(layer: &Layer, pr: &Property) -> Option { None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), - Some("masks") => mask(&chain, pr), - Some("text") => text_prop(&chain, pr), + Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr), _ => None, } } -/// Text animators and selectors by match id, with the kind After Effects names them by. -const TEXT_INSTANCES: &[(&str, &str)] = - &[("animator", "Animator"), ("rangeSelector", "Range Selector"), ("wigglySelector", "Wiggly Selector"), ("expressionSelector", "Expression Selector")]; +/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind +/// and place (`Mask #2`, `Group #1`), not by name. +const INSTANCES: &[(&str, &str)] = &[ + ("mask", "Mask"), + ("animator", "Animator"), + ("rangeSelector", "Range Selector"), + ("wigglySelector", "Wiggly Selector"), + ("expressionSelector", "Expression Selector"), + ("group", "Group"), + ("rect", "Rectangle Path"), + ("ellipse", "Ellipse Path"), + ("star", "Polystar Path"), + ("path", "Path"), + ("fill", "Fill"), + ("stroke", "Stroke"), + ("gfill", "Gradient Fill"), + ("gstroke", "Gradient Stroke"), + ("trim", "Trim Paths"), + ("repeater", "Repeater"), + ("round", "Round Corners"), + ("offset", "Offset Paths"), + ("pucker", "Pucker & Bloat"), + ("twist", "Twist"), + ("zigzag", "Zig Zag"), + ("wiggle", "Wiggle Paths"), + ("merge", "Merge Paths"), +]; -/// A text property: `Text`, then each group down to the property. Animators and selectors go by -/// kind and place (`Animator #1`), not name, and a selector's own properties carry their place -/// too (`Start #1`). Source Text isn't a number, and After Effects leaves it out. -fn text_prop(chain: &[&Node], pr: &Property) -> Option { +/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's +/// name, then each group down to the property. Instances go by kind and place, and a text +/// selector's own properties carry their place too (`Start #1`). +fn nested(chain: &[&Node], pr: &Property) -> Option { let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); let parent = groups.last()?; - // Path Options' Path picks a mask: not a value the text carries. - if parent.match_id == "pathOptions" && pr.match_id == "path" { - return None; - } - let (labels, values) = by_ui(pr)?; - let mut header = String::from("Text"); + let (labels, values) = columns(&parent.match_id, pr)?; + let mut header = groups.first()?.name.clone(); for pair in groups.windows(2) { let [outer, g] = pair else { continue }; - match TEXT_INSTANCES.iter().find(|(id, _)| *id == g.match_id) { + match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) { Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)), None => header.push_str(&format!("\t{}", g.name)), } @@ -168,19 +189,6 @@ fn text_prop(chain: &[&Node], pr: &Property) -> Option { Some(Channel { header, labels, values }) } -/// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a -/// number, and After Effects leaves it out. -fn mask(chain: &[&Node], pr: &Property) -> Option { - let [Node::Group(masks), Node::Group(m), _] = chain else { return None }; - let unit = match pr.match_id.as_str() { - "feather" | "expansion" => "pixels", - "opacity" => "percent", - _ => return None, - }; - let header = format!("Masks\tMask #{}\t{}", place(masks, m.uid)?, pr.name); - Some(Channel { header, labels: labels(unit, pr.value.dims()), values: Values::Plain }) -} - /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, /// then the property's name and After Effects index. Properties in twirl-down groups are /// written without their group, as After Effects does. @@ -188,7 +196,7 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = by_ui(pr)?; + let (labels, values) = columns(effect, pr)?; let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } @@ -231,6 +239,47 @@ fn place(group: &PropGroup, uid: Uid) -> Option { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) } +/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]). +#[derive(Clone, Copy)] +enum Exception { + /// Not written. + Skip, + Unit(&'static str), + Values(Values), +} + +/// Properties After Effects writes differently from what their editor suggests, by the effect +/// or group they're directly in and their match id. +const EXCEPTIONS: &[(&str, &str, Exception)] = &[ + // Path Options' Path picks a mask, which After Effects doesn't write. + ("pathOptions", "path", Exception::Skip), + ("mask", "feather", Exception::Unit("pixels")), + ("mask", "expansion", Exception::Unit("pixels")), + ("cameraOptions", "zoom", Exception::Unit("pixels")), + ("cameraOptions", "focusDistance", Exception::Unit("pixels")), + ("cameraOptions", "aperture", Exception::Unit("pixels")), + ("cameraOptions", "highlightGain", Exception::Unit("")), + ("cameraOptions", "highlightSaturation", Exception::Unit("")), + // After Effects keeps the threshold as 0–1. + ("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))), + ("lightOptions", "shadowDiffusion", Exception::Unit("pixels")), + // Off, On, Only: written from 0. + ("materialOptions", "castsShadows", Exception::Values(Values::Plain)), + ("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)), +]; + +/// Column labels and values for a property in `scope`, the effect (by id) or group (by match +/// id) it's directly in: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. +fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr)?; + Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { + None => (by_editor, values), + Some(Exception::Skip) => return None, + Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values), + Some(Exception::Values(values)) => (by_editor, values), + }) +} + /// Column labels and values for an effect or text property, by how it's edited. After Effects /// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other /// sliders, checkboxes and popups with no unit. @@ -263,12 +312,13 @@ fn transform(layer: &Layer, pr: &Property) -> Option { Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) } -/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`). +/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`; `X ` with no unit, as After +/// Effects writes a shape's Size). fn labels(unit: &str, dims: usize) -> Vec { match dims { 0 => vec![], 1 => vec![unit.to_string()], - _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}").trim_end().to_string()).collect(), + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), } } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index d40e79e67..6de5e54be 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -396,3 +396,62 @@ fn keys_from_two_layers_have_no_text() { s.execute("edit.copy", json!({"layers": [a.0]})).unwrap(); assert_eq!(s.state.keyframe_text, None); } + +#[test] +fn shape_contents_are_written_as_after_effects_does() { + // Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and + // place in their group, and a Size has no unit (`X `). + let (mut s, _) = session(); + let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap()); + for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] { + let mut p = json!({"layer": sh.0, "kind": kind}); + if let Some(g) = group { + p["group"] = json!(g); + } + s.execute("layer.addShapeItem", p).unwrap(); + } + let text = copy_props( + &mut s, + sh, + &[ + "contents/#1/contents/#1/size", + "contents/#1/contents/#2/color", + "contents/#1/transform/position", + "contents/#2/points", + "contents/#3/transform/startOpacity", + ], + ); + for block in [ + "Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n", + "Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n", + "Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n", + "Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n", + "Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + +#[test] +fn camera_light_and_material_options_are_written_as_after_effects_does() { + // Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow + // Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0. + let (mut s, l) = session(); + let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]); + assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}"); + assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}"); + let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap()); + let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]); + assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}"); + assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]); + for block in [ + "Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n", + "Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n", + "Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} From 5556b580504ab7e6205e407e4b038221b299d72a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 11:12:52 -0400 Subject: [PATCH 0253/1386] Clipboard: effect sliders of -100 to 100 have no unit in keyframe text, as in After Effects (#1908) --- crates/engine/src/keyframe_text.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index e57909b15..38af4b783 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -172,7 +172,7 @@ const INSTANCES: &[(&str, &str)] = &[ fn nested(chain: &[&Node], pr: &Property) -> Option { let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); let parent = groups.last()?; - let (labels, values) = columns(&parent.match_id, pr)?; + let (labels, values) = columns(&parent.match_id, pr, false)?; let mut header = groups.first()?.name.clone(); for pair in groups.windows(2) { let [outer, g] = pair else { continue }; @@ -196,7 +196,7 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = columns(effect, pr)?; + let (labels, values) = columns(effect, pr, true)?; let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } @@ -270,8 +270,8 @@ const EXCEPTIONS: &[(&str, &str, Exception)] = &[ /// Column labels and values for a property in `scope`, the effect (by id) or group (by match /// id) it's directly in: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. -fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { - let (by_editor, values) = by_ui(pr)?; +fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr, in_effect)?; Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { None => (by_editor, values), Some(Exception::Skip) => return None, @@ -280,15 +280,16 @@ fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { }) } -/// Column labels and values for an effect or text property, by how it's edited. After Effects -/// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other -/// sliders, checkboxes and popups with no unit. -fn by_ui(pr: &Property) -> Option<(Vec, Values)> { +/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100 +/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts), +/// and other sliders, checkboxes and popups with no unit. For the 1,390 effect parameters +/// EffectCraft shares with After Effects 2019, these rules give After Effects' unit for 88%. +fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), - ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || *min == -100.0) => (labels("percent", dims), Values::Plain), + ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || (*min == -100.0 && !in_effect)) => (labels("percent", dims), Values::Plain), ParamUi::Percent => (labels("percent", dims), Values::Plain), ParamUi::Angle => (labels("degrees", dims), Values::Plain), ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), From 9db7f3b21700fd821f215782ed1c82afe077880f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:39:09 -0400 Subject: [PATCH 0254/1386] Skip CodeRabbit reviews on Dependabot PRs (#1962) Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot]. Co-authored-by: Claude Opus 5.5 --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml 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]" From 3878fdf0ba7826337d7874e08e8f557013a947f0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:46:10 +0200 Subject: [PATCH 0255/1386] Make Gaussian Blur and Fast Box Blur continuous at small values (#1939) --- crates/effects/src/misc.rs | 68 +++++++++++++++++++++++++++++--- crates/effects/src/obsolete.rs | 18 ++++++++- crates/gpu/src/effects.rs | 11 +++++- crates/gpu/src/fx_extra.rs | 4 ++ crates/gpu/src/tests.rs | 23 +++++++++-- crates/gpu/src/tests_fx_extra.rs | 5 ++- crates/raster/src/blur.rs | 47 +++++++++++++++++++++- crates/raster/src/lib.rs | 2 +- 8 files changed, 162 insertions(+), 16 deletions(-) diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..7468c6b84 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -35,22 +35,44 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); } - b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); + let (sx, sy) = (s * kx, s * ky); + // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). + b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) { + effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, repeat || ctx.adjustment) + } else { + gaussian_blur(&b.img, sx, sy, repeat || ctx.adjustment) + }; b } fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; - if r == 0 { + let radius = (ctx.params.f("radius") * b.scale).max(0.0); + let r = radius.floor() as usize; + let frac = (radius - r as f64) as f32; + // Fractional radius: blend the two neighbouring integer radii so the result changes continuously (#1939). + let frac = if frac < 1e-4 { 0.0 } else { frac }; + if r == 0 && frac == 0.0 { return b; } let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize; let (kx, ky) = dims_xy(ctx.params.e("dimensions")); let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { - b.pad((r * it) as u32 + 1); + let reach = if frac > 0.0 { r + 1 } else { r }; + b.pad((reach * it) as u32 + 1); + } + let edge = repeat || ctx.adjustment; + let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); + if frac == 0.0 { + b.img = lo; + return b; + } + let hi = effectcraft_raster::box_blur(&b.img, (r + 1) * kx as usize, (r + 1) * ky as usize, it, edge); + for ((l, h), o) in lo.data.iter().zip(hi.data.iter()).zip(b.img.data.iter_mut()) { + for c in 0..4 { + o[c] = l[c] + (h[c] - l[c]) * frac; + } } - b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b } @@ -738,6 +760,42 @@ mod tests { use super::*; use crate::{EffectEnv, run_fx}; + /// White left half, black right half (one vertical edge at x = 20). + fn edge() -> effectcraft_raster::Image { + let mut img = effectcraft_raster::Image::filled(40, 8, [0.0, 0.0, 0.0, 1.0]); + for y in 0..8 { + for x in 0..20 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + fn blurred_row(id: &str, param: &str, v: f64) -> Vec { + let out = run_fx(id, &[(param, num(v)), ("repeatEdge", Value::Bool(true))], edge(), 0.0, EffectEnv::default()); + (16..24).map(|x| out.img.get(x, 4)[0]).collect() + } + + #[test] + fn small_gaussian_blurriness_is_continuous() { + // #1939: 0.9 used to be identical to 0, and 1.2 / 1.6 / 2.0 identical to each other. + let row = |v| blurred_row("ec.blur.gaussian", "blurriness", v); + assert_ne!(row(0.0), row(0.9)); + assert_ne!(row(0.9), row(1.2)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.0)); + assert_ne!(row(2.0), row(2.4)); + } + + #[test] + fn fractional_box_blur_radius_is_continuous() { + let row = |v| blurred_row("ec.blur.fastbox", "radius", v); + assert_ne!(row(0.0), row(0.5)); + assert_ne!(row(0.5), row(0.9)); + assert_ne!(row(1.2), row(1.6)); + assert_ne!(row(1.6), row(2.4)); + } + /// A bright square on mid grey. fn scene() -> effectcraft_raster::Image { let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]); diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs index c58f5ca6b..a2b161b39 100644 --- a/crates/effects/src/obsolete.rs +++ b/crates/effects/src/obsolete.rs @@ -99,7 +99,13 @@ fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { if !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); } - b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment); + let (sx, sy) = (s * kx, s * ky); + // Small sigmas use the sampled kernel, like Gaussian Blur (#1939). + b.img = if effectcraft_raster::gaussian_is_exact(sx, sy) { + effectcraft_raster::gaussian_blur_exact(&b.img, sx, sy, ctx.adjustment) + } else { + gaussian_blur(&b.img, sx, sy, ctx.adjustment) + }; b } @@ -175,6 +181,16 @@ mod tests { assert!(out.img.get(c as i64, c as i64)[3] < 0.5); } + #[test] + fn gaussian_legacy_small_blurriness_blurs() { + // #1939: Blurriness under ~1.2 used to be a no-op. + let mut img = Image::new(15, 15); + img.set(7, 7, [1.0, 1.0, 1.0, 1.0]); + let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(0.9))], img, 0.0, EffectEnv::default()); + let c = out.img.width / 2; + assert!(out.img.get(c as i64, c as i64)[3] < 0.9); + } + #[test] fn moved_effects_are_obsolete() { for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..09aec69d6 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -261,6 +261,10 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { return Some(b); } let (kx, ky) = dims_xy(ctx.params.e("dimensions")); + // Small sigmas use the CPU's sampled kernel (#1939). + if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) { + return None; + } let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; @@ -270,7 +274,12 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { - let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + let radius = (ctx.params.f("radius") * b.scale).max(0.0); + // Fractional radii blend two integer radii on the CPU (#1939). + if radius - radius.floor() >= 1e-4 { + return None; + } + let r = radius.floor() as usize; if r == 0 { return Some(b); } diff --git a/crates/gpu/src/fx_extra.rs b/crates/gpu/src/fx_extra.rs index 2e8bb85b8..cff516305 100644 --- a/crates/gpu/src/fx_extra.rs +++ b/crates/gpu/src/fx_extra.rs @@ -188,6 +188,10 @@ fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { 2 => (0.0, 1.0), _ => (1.0, 1.0), }; + // Small sigmas use the CPU's sampled kernel (#1939). + if effectcraft_raster::gaussian_is_exact(s * kx, s * ky) { + return None; + } if !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..de1a20df8 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -491,12 +491,27 @@ pub(crate) fn c(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } +#[test] +fn small_blurs_fall_back_to_the_cpu() { + // #1939: small Gaussian sigmas and fractional box radii render on the CPU kernel. + for (id, vals) in [("ec.blur.gaussian", [("blurriness", n(0.9))]), ("ec.blur.fastbox", [("radius", n(1.5))])] { + let mut s = Scene::new(BitDepth::Bpc32); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut l = s.footage(70, 44); + s.effect(&mut l, id, &vals); + s.push(l); + check(&format!("{id} {vals:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + #[test] fn gpu_effects_match_cpu() { - effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]); - effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]); - effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]); - effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]); + // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939). + effect_case("ec.blur.gaussian", &[("blurriness", n(14.0))]); + effect_case("ec.blur.gaussian", &[("blurriness", n(12.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]); + effect_case("ec.blur.fastbox", &[("radius", n(6.0)), ("iterations", n(2.0))]); + effect_case("ec.blur.fastbox", &[("radius", n(4.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]); effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]); effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]); effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]); diff --git a/crates/gpu/src/tests_fx_extra.rs b/crates/gpu/src/tests_fx_extra.rs index b7fe6cbd2..136a66d2f 100644 --- a/crates/gpu/src/tests_fx_extra.rs +++ b/crates/gpu/src/tests_fx_extra.rs @@ -46,6 +46,7 @@ fn bevels_and_legacy_gaussian() { effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]); effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]); effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]); - effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]); - effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]); + // Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939). + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]); + effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]); } diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 1c065d77b..fcafee5dd 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -1,5 +1,5 @@ -//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional -//! and radial (spin/zoom) blurs. +//! Blurs: separable box passes (exact running sums) approximating Gaussians, a sampled Gaussian +//! kernel for small sigmas, plus directional and radial (spin/zoom) blurs. //! //! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over //! blocks of rows with a per-column running sum (no transposes), ping-ponging between two @@ -157,6 +157,49 @@ pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> I box_passes(img, &rx, &ry, repeat) } +/// Below this sigma (pixels) three integer-radius box passes are too coarse (sigma under ~0.6 is +/// a no-op and the result steps between a few shapes), so [`gaussian_blur_exact`] is used instead. +const EXACT_SIGMA_BELOW: f64 = 3.0; + +/// True when a Gaussian of these per-axis sigmas should use [`gaussian_blur_exact`]. +pub fn gaussian_is_exact(sigma_x: f64, sigma_y: f64) -> bool { + sigma_x.max(sigma_y) < EXACT_SIGMA_BELOW +} + +/// One separable pass of a sampled Gaussian (radius 3 sigma) along x or y. +fn gauss_pass(img: &Image, sigma: f64, vertical: bool, repeat: bool) -> Image { + if sigma <= 0.0 || img.is_empty() { + return img.clone(); + } + let r = (sigma * 3.0).ceil() as i64; + let mut k: Vec = (-r..=r).map(|i| (-((i * i) as f64) / (2.0 * sigma * sigma)).exp()).collect(); + let total: f64 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= total); + let w = img.width as usize; + let mut out = Image::new(img.width, img.height); + out.rows_mut().for_each(|(y, row)| { + for x in 0..w { + let mut acc = [0.0f64; 4]; + for (i, wt) in k.iter().enumerate() { + let d = i as i64 - r; + let (sx, sy) = if vertical { (x as i64, y as i64 + d) } else { (x as i64 + d, y as i64) }; + let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) }; + for c in 0..4 { + acc[c] += p[c] as f64 * wt; + } + } + row[x] = acc.map(|v| v as f32); + } + }); + out +} + +/// Gaussian blur as a true sampled kernel, continuous in sigma (for small sigmas, see +/// [`gaussian_is_exact`]). +pub fn gaussian_blur_exact(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image { + gauss_pass(&gauss_pass(img, sigma_x, false, repeat), sigma_y, true, repeat) +} + /// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from /// up) over `length` pixels. pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image { diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index f17602829..dce8dda81 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -16,7 +16,7 @@ pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; use rayon::prelude::*; -pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur}; +pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur}; pub use channels3d::AuxChannels; pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; From b3ff5d4aa6754fcbe40990a3a4c6dd9fbf0deec6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:51:57 +0200 Subject: [PATCH 0256/1386] Fix feather points when deleting first vertex of a closed mask path Found while testing crates/keyframe/src/value.rs:79-95 (ShapePath::feather_after_remove, called from crates/engine/src/commands/mask.rs:330) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/keyframe/src/value.rs | 13 ++++++++++++- crates/keyframe/tests/feather_remove.rs | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 crates/keyframe/tests/feather_remove.rs diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 1dff4477f..6609aed68 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -82,7 +82,18 @@ impl ShapePath { if i == 0 && f.segment == 0 && !self.closed { return false; } - if i > 0 && f.segment == i - 1 { + if i == 0 && self.closed && segs > 0 { + // The old closing segment and segment 0 merge into the new closing one. + if f.segment == segs { + f.segment = segs - 1; + f.t *= 0.5; + } else if f.segment == 0 { + f.segment = segs - 1; + f.t = 0.5 + f.t * 0.5; + } else { + f.segment -= 1; + } + } else if i > 0 && f.segment == i - 1 { f.t *= 0.5; } else if f.segment == i && i > 0 { f.segment = i - 1; diff --git a/crates/keyframe/tests/feather_remove.rs b/crates/keyframe/tests/feather_remove.rs new file mode 100644 index 000000000..06836bbf6 --- /dev/null +++ b/crates/keyframe/tests/feather_remove.rs @@ -0,0 +1,14 @@ +use effectcraft_keyframe::{FeatherPoint, ShapePath}; + +#[test] +fn removing_first_vertex_of_closed_path_merges_closing_segment() { + let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0); + let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 }; + p.feather = vec![fp(3), fp(0)]; + p.vertices.remove(0); + p.in_tangents.remove(0); + p.out_tangents.remove(0); + p.feather_after_remove(0); + let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect(); + assert_eq!(got, vec![(2, 0.25), (2, 0.75)]); +} From cfb762b1143e153ef768335ecaa8f13d3aeb4f50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 13:41:34 -0400 Subject: [PATCH 0257/1386] Clipboard: effect parameters After Effects numbers or labels otherwise follow it in keyframe text (#1908) A table of After Effects' index and unit for the effect parameters its keyframe text gives differently from EffectCraft's rules, read from After Effects 2019's text for 230 effects. All 1,530 parameters the two share now match; 949 did before. --- crates/engine/src/keyframe_text.rs | 36 +- crates/engine/src/keyframe_text_effects.rs | 386 +++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests_keyframe_text.rs | 25 ++ 4 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/keyframe_text_effects.rs diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 38af4b783..5f8ba9441 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -6,7 +6,7 @@ use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; -use crate::KeyRef; +use crate::{KeyRef, keyframe_text_effects}; /// Transform properties with the unit their columns are labelled with and, for vectors, the /// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit. @@ -172,7 +172,7 @@ const INSTANCES: &[(&str, &str)] = &[ fn nested(chain: &[&Node], pr: &Property) -> Option { let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); let parent = groups.last()?; - let (labels, values) = columns(&parent.match_id, pr, false)?; + let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?; let mut header = groups.first()?.name.clone(); for pair in groups.windows(2) { let [outer, g] = pair else { continue }; @@ -196,28 +196,31 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = columns(effect, pr, true)?; - let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); + let path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect(); + let (labels, values) = columns(effect, &path, pr, true)?; + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?); Some(Channel { header, labels, values }) } /// After Effects' index of an effect property: the effect's hidden input layer is #1, and a /// twirl-down group takes an index where it opens and another where it closes. The hidden -/// source choice EffectCraft keeps beside a layer parameter has none. -fn effect_index(fx: &PropGroup, uid: Uid) -> Option { - fn count(g: &PropGroup, uid: Uid, n: &mut usize) -> bool { +/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects +/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it. +fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option { + fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool { for c in &g.children { match c { Node::Prop(p) if is_layer_source(g, p) => {} Node::Prop(p) => { - *n += 1; + let path = format!("{prefix}{}", p.match_id); + *n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2); if p.uid == uid { return true; } } Node::Group(sub) => { *n += 1; - if count(sub, uid, n) { + if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) { return true; } *n += 1; @@ -227,7 +230,7 @@ fn effect_index(fx: &PropGroup, uid: Uid) -> Option { false } let mut n = 1; - count(fx, uid, &mut n).then_some(n) + count(effect, fx, "", uid, &mut n).then_some(n) } fn is_layer_source(g: &PropGroup, p: &Property) -> bool { @@ -269,10 +272,13 @@ const EXCEPTIONS: &[(&str, &str, Exception)] = &[ ]; /// Column labels and values for a property in `scope`, the effect (by id) or group (by match -/// id) it's directly in: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. -fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { +/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`] +/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise. +fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { let (by_editor, values) = by_ui(pr, in_effect)?; - Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { + let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2); + let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2)); + Some(match exception.or_else(effect_unit) { None => (by_editor, values), Some(Exception::Skip) => return None, Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values), @@ -282,8 +288,8 @@ fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, /// Column labels and values for a property, by how it's edited. After Effects labels most 0–100 /// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts), -/// and other sliders, checkboxes and popups with no unit. For the 1,390 effect parameters -/// EffectCraft shares with After Effects 2019, these rules give After Effects' unit for 88%. +/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists +/// the effect parameters After Effects labels otherwise. fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { diff --git a/crates/engine/src/keyframe_text_effects.rs b/crates/engine/src/keyframe_text_effects.rs new file mode 100644 index 000000000..83f5b6c20 --- /dev/null +++ b/crates/engine/src/keyframe_text_effects.rs @@ -0,0 +1,386 @@ +//! After Effects' index and unit for effect parameters where they differ from what +//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text. +//! After Effects keeps an effect's parameter indices for good (projects store them), so they +//! hold for later versions. Parameters After Effects lacks, or names differently and keeps +//! elsewhere, aren't listed. + +/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After +/// Effects' index for it; the parameters after it count on from there. After Effects numbers +/// legacy parameters it hides, so its indices skip ahead. +pub(crate) const INDICES: &[(&str, &str, usize)] = &[ + ("ec.3d.channelextract", "clampOutput", 6), + ("ec.3d.channelextract", "antialias", 5), + ("ec.3d.extractor", "blackPoint", 4), + ("ec.audio.parametriceq", "band1Enable", 3), + ("ec.blur.radial", "amount", 3), + ("ec.color.cccolorneutralizer", "midtonesUnbalance", 8), + ("ec.color.cccolorneutralizer", "highlightsUnbalance", 14), + ("ec.color.cccolorneutralizer", "pinning", 20), + ("ec.color.cckernel", "k1", 3), + ("ec.color.cckernel", "k4", 8), + ("ec.color.cckernel", "k7", 13), + ("ec.color.cckernel", "scale", 17), + ("ec.color.cckernel", "absolute", 18), + ("ec.color.colorstabilizer", "stabilize", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.levels", "inBlack", 4), + ("ec.color.levelsic", "rgb/rgbInBlack", 5), + ("ec.color.lumetri", "highDynamicRange", 3), + ("ec.color.lumetri", "basicCorrection/inputLut", 8), + ("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12), + ("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22), + ("ec.color.lumetri", "basicCorrection/saturation", 26), + ("ec.color.lumetri", "creative/look", 32), + ("ec.color.lumetri", "creative/lookIntensity", 33), + ("ec.color.lumetri", "creative/adjustments/tintBalance", 40), + ("ec.color.lumetri", "hslSecondary/key/showMask", 83), + ("ec.color.lumetri", "hslSecondary/refine/denoise", 90), + ("ec.color.lumetri", "hslSecondary/correction/temperature", 96), + ("ec.color.lumetri", "vignette/vignetteAmount", 105), + ("ec.color.selectivecolor", "details/reds/redsCyan", 10), + ("ec.color.videolimiter", "compressionBeforeClipping", 3), + ("ec.distort.ccflomotion", "knot1", 3), + ("ec.distort.ccpowerpin", "expandTop", 9), + ("ec.distort.upscale", "scale", 4), + ("ec.distort.puppet", "refinement", 3), + ("ec.control.stereo3d", "configuration", 3), + ("ec.control.stereo3d", "convergenceOf", 8), + ("ec.generate.advancedlightning", "turbulence", 17), + ("ec.generate.advancedlightning", "alphaObstacle", 16), + ("ec.generate.advancedlightning", "compositeOnOriginal", 21), + ("ec.generate.ccgluegun", "style", 9), + ("ec.generate.cclightburst", "setColor", 7), + ("ec.generate.cclightrays", "colorFromSource", 8), + ("ec.generate.cclightrays", "color", 10), + ("ec.generate.circle", "feather/feather", 7), + ("ec.generate.paintbucket", "color", 9), + ("ec.vr.blur", "frameLayout", 3), + ("ec.vr.blur", "blurriness", 6), + ("ec.vr.chromaticaberrations", "frameLayout", 3), + ("ec.vr.chromaticaberrations", "aberrationRed", 7), + ("ec.vr.colorgradients", "frameLayout", 3), + ("ec.vr.colorgradients", "point1", 10), + ("ec.vr.colorgradients", "point2", 12), + ("ec.vr.colorgradients", "point3", 14), + ("ec.vr.colorgradients", "point4", 16), + ("ec.vr.colorgradients", "point5", 18), + ("ec.vr.colorgradients", "blend", 7), + ("ec.vr.colorgradients", "opacity", 27), + ("ec.vr.denoise", "frameLayout", 3), + ("ec.vr.denoise", "noiseType", 6), + ("ec.vr.digitalglitch", "frameLayout", 3), + ("ec.vr.digitalglitch", "masterAmplitude", 9), + ("ec.vr.digitalglitch", "distortionRate", 16), + ("ec.vr.digitalglitch", "distortionComplexity", 15), + ("ec.vr.digitalglitch", "distortionEvolution", 17), + ("ec.vr.digitalglitch", "colorDistortion", 11), + ("ec.vr.digitalglitch", "randomSeed", 39), + ("ec.vr.fractalnoise", "frameLayout", 3), + ("ec.vr.fractalnoise", "fractalType", 6), + ("ec.vr.fractalnoise", "randomSeed", 25), + ("ec.vr.glow", "frameLayout", 3), + ("ec.vr.glow", "luminanceThreshold", 6), + ("ec.vr.planetosphere", "frameLayout", 3), + ("ec.vr.planetosphere", "scale", 6), + ("ec.vr.planetosphere", "feather", 8), + ("ec.vr.planetosphere", "tilt", 15), + ("ec.vr.rotatesphere", "frameLayout", 3), + ("ec.vr.rotatesphere", "invertRotation", 9), + ("ec.vr.rotatesphere", "tilt", 6), + ("ec.vr.sharpen", "frameLayout", 3), + ("ec.vr.sharpen", "sharpenAmount", 6), + ("ec.key.colordifference", "view", 3), + ("ec.key.colorrange", "fuzziness", 3), + ("ec.key.extract", "channel", 3), + ("ec.key.linearcolor", "view", 3), + ("ec.matte.refinehard", "feather", 2), + ("ec.matte.refinehard", "reduceChatter", 5), + ("ec.matte.refinehard", "decontaminationAmount", 14), + ("ec.matte.refinesoft", "reduceChatter", 10), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", 13), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22), + ("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27), + ("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29), + ("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8), + ("ec.obsolete.basictext", "position", 2), + ("ec.key.spill", "suppression", 3), + ("ec.perspective.cameratracker", "shotType", 4), + ("ec.perspective.cameratracker", "advanced/solveMethod", 12), + ("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17), + ("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18), + ("ec.perspective.cccylinder", "rotationX", 9), + ("ec.perspective.cccylinder", "render", 13), + ("ec.perspective.cccylinder", "lightIntensity", 15), + ("ec.perspective.cccylinder", "ambient", 21), + ("ec.sim.ccdrizzle", "light/lightIntensity", 10), + ("ec.sim.ccdrizzle", "light/lightHeight", 13), + ("ec.sim.ccdrizzle", "light/lightDirection", 15), + ("ec.sim.ccmrmercury", "blobBirthSize", 15), + ("ec.sim.ccmrmercury", "light/lightIntensity", 19), + ("ec.sim.ccmrmercury", "light/lightHeight", 22), + ("ec.sim.ccmrmercury", "light/lightDirection", 24), + ("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24), + ("ec.sim.ccparticlesystems2", "particle/birthColor", 27), + ("ec.sim.ccrainfall", "transferMode", 16), + ("ec.sim.ccrainfall", "extras/randomSeed", 23), + ("ec.sim.ccsnowfall", "color", 19), + ("ec.sim.ccsnowfall", "transferMode", 26), + ("ec.sim.ccsnowfall", "extras/randomSeed", 32), + ("ec.stylize.ccblockload", "startCleared", 4), + ("ec.stylize.cchextile", "rotate", 6), + ("ec.stylize.glow", "colorLooping", 9), + ("ec.text.timecode", "timeUnits", 5), + ("ec.text.timecode", "textPosition", 9), + ("ec.utility.facemeasurements", "headOrientationX", 9), + ("ec.utility.facemeasurements", "rightEyebrowRaise", 20), + ("ec.utility.facemeasurements", "mouthOffsetX", 26), + ("ec.utility.facetrackpoints", "leftEyebrowInner", 10), + ("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12), + ("ec.utility.facetrackpoints", "leftEyebrowOuter", 14), + ("ec.utility.facetrackpoints", "rightEyebrowInner", 24), + ("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26), + ("ec.utility.facetrackpoints", "rightEyebrowOuter", 28), + ("ec.utility.facetrackpoints", "leftEyeInner", 15), + ("ec.utility.facetrackpoints", "leftEyeOuter", 21), + ("ec.utility.facetrackpoints", "leftPupil", 20), + ("ec.utility.facetrackpoints", "rightEyeInner", 29), + ("ec.utility.facetrackpoints", "rightEyeOuter", 35), + ("ec.utility.facetrackpoints", "rightPupil", 34), + ("ec.utility.facetrackpoints", "noseTip", 41), + ("ec.utility.facetrackpoints", "leftNostril", 44), + ("ec.utility.facetrackpoints", "rightNostril", 46), + ("ec.utility.facetrackpoints", "mouthLeft", 49), + ("ec.utility.facetrackpoints", "mouthRight", 55), + ("ec.utility.facetrackpoints", "mouthTop", 52), + ("ec.utility.facetrackpoints", "mouthBottom", 58), + ("ec.utility.facetrackpoints", "chin", 84), +]; + +/// Effect id, parameter path and the unit After Effects labels the parameter's columns with. +pub(crate) const UNITS: &[(&str, &str, &str)] = &[ + ("ec.3d.depthoffield", "focalBias", ""), + ("ec.3d.idmatte", "feather", ""), + ("ec.audio.flangechorus", "voiceSeparationTime", ""), + ("ec.audio.flangechorus", "voicePhaseChange", ""), + ("ec.audio.modulator", "modulationRate", ""), + ("ec.audio.parametriceq", "band1BoostCut", "percent"), + ("ec.audio.parametriceq", "band2BoostCut", "percent"), + ("ec.audio.parametriceq", "band3BoostCut", "percent"), + ("ec.audio.stereomixer", "leftLevel", "percent"), + ("ec.audio.stereomixer", "rightLevel", "percent"), + ("ec.audio.stereomixer", "leftPan", "percent"), + ("ec.audio.stereomixer", "rightPan", "percent"), + ("ec.blur.bilateral", "threshold", ""), + ("ec.blur.ccradial", "amount", "pixels"), + ("ec.blur.ccradialfast", "amount", ""), + ("ec.blur.camerashakedeblur", "strength", "percent"), + ("ec.blur.radial", "amount", ""), + ("ec.blur.smart", "radius", ""), + ("ec.blur.smart", "threshold", ""), + ("ec.color.autocolor", "blackClip", "percent"), + ("ec.color.autocolor", "whiteClip", "percent"), + ("ec.color.autocontrast", "blackClip", "percent"), + ("ec.color.autocontrast", "whiteClip", "percent"), + ("ec.color.autolevels", "blackClip", "percent"), + ("ec.color.autolevels", "whiteClip", "percent"), + ("ec.color.brightnesscontrast", "contrast", "pixels"), + ("ec.color.cccolorneutralizer", "pinning", ""), + ("ec.color.cccolorneutralizer", "blendWOriginal", ""), + ("ec.color.changecolor", "lightnessTransform", "pixels"), + ("ec.color.changecolor", "saturationTransform", "pixels"), + ("ec.color.colorbalance", "shadowRed", "pixels"), + ("ec.color.colorbalance", "shadowGreen", "pixels"), + ("ec.color.colorbalance", "shadowBlue", "pixels"), + ("ec.color.colorbalance", "midRed", "pixels"), + ("ec.color.colorbalance", "midGreen", "pixels"), + ("ec.color.colorbalance", "midBlue", "pixels"), + ("ec.color.colorbalance", "hiRed", "pixels"), + ("ec.color.colorbalance", "hiGreen", "pixels"), + ("ec.color.colorbalance", "hiBlue", "pixels"), + ("ec.color.colorbalancehls", "lightness", "pixels"), + ("ec.color.colorbalancehls", "saturation", "pixels"), + ("ec.color.colorstabilizer", "sampleSize", "percent"), + ("ec.color.gammapedestalgain", "blackStretch", "percent"), + ("ec.color.huesaturation", "saturation", "pixels"), + ("ec.color.huesaturation", "lightness", "pixels"), + ("ec.color.huesaturation", "colorizeSaturation", ""), + ("ec.color.huesaturation", "colorizeLightness", "pixels"), + ("ec.color.lumetri", "creative/adjustments/fadedFilm", ""), + ("ec.color.lumetri", "hslSecondary/refine/denoise", ""), + ("ec.color.lumetri", "hslSecondary/refine/blur", ""), + ("ec.color.lumetri", "vignette/vignetteMidpoint", ""), + ("ec.color.lumetri", "vignette/vignetteFeather", ""), + ("ec.color.psarbitrarymap", "phase", "degrees"), + ("ec.color.selectivecolor", "details/reds/redsCyan", "percent"), + ("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"), + ("ec.color.selectivecolor", "details/reds/redsYellow", "percent"), + ("ec.color.selectivecolor", "details/reds/redsBlack", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"), + ("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"), + ("ec.color.selectivecolor", "details/greens/greensCyan", "percent"), + ("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"), + ("ec.color.selectivecolor", "details/greens/greensYellow", "percent"), + ("ec.color.selectivecolor", "details/greens/greensBlack", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"), + ("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"), + ("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"), + ("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"), + ("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"), + ("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"), + ("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"), + ("ec.color.shadowhighlight", "shadowAmount", ""), + ("ec.color.shadowhighlight", "highlightAmount", ""), + ("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""), + ("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"), + ("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"), + ("ec.distort.bulge", "height", "pixels"), + ("ec.distort.bulge", "taperRadius", ""), + ("ec.distort.ccbendit", "bend", "pixels"), + ("ec.distort.ccbender", "amount", "pixels"), + ("ec.distort.ccgriddler", "tileSize", "percent"), + ("ec.distort.cctiler", "scale", "percent"), + ("ec.distort.upscale", "scale", "percent"), + ("ec.distort.magnify", "magnification", "pixels"), + ("ec.distort.magnify", "size", "percent"), + ("ec.distort.puppet", "refinement", ""), + ("ec.distort.ripple", "radius", ""), + ("ec.distort.ripple", "speed", "percent"), + ("ec.distort.transform", "opacity", ""), + ("ec.distort.turbulentdisplace", "complexity", "percent"), + ("ec.distort.twirl", "radius", ""), + ("ec.distort.wavewarp", "width", "percent"), + ("ec.distort.wavewarp", "speed", "pixels"), + ("ec.generate.fourcolor", "blend", "percent"), + ("ec.generate.advancedlightning", "coreSettings/coreRadius", ""), + ("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"), + ("ec.generate.advancedlightning", "alphaObstacle", "pixels"), + ("ec.generate.ccgluegun", "reflection", ""), + ("ec.generate.cclightburst", "rayLength", ""), + ("ec.generate.cclightsweep", "edgeThickness", ""), + ("ec.generate.ccthreads", "coverage", ""), + ("ec.generate.ccthreads", "shadowing", ""), + ("ec.generate.ccthreads", "texture", ""), + ("ec.generate.checkerboard", "width", "percent"), + ("ec.generate.checkerboard", "height", "percent"), + ("ec.generate.grid", "width", "percent"), + ("ec.generate.grid", "height", "percent"), + ("ec.generate.lensflare", "flareBrightness", "percent"), + ("ec.vr.chromaticaberrations", "falloff", ""), + ("ec.vr.denoise", "noiseLevel", ""), + ("ec.vr.digitalglitch", "colorDistortion", ""), + ("ec.vr.fractalnoise", "subSettings/subInfluence", ""), + ("ec.vr.glow", "luminanceThreshold", ""), + ("ec.vr.planetosphere", "feather", ""), + ("ec.vr.sharpen", "sharpenAmount", ""), + ("ec.key.advancedspill", "suppression", "percent"), + ("ec.key.advancedspill", "ultraSettings/tolerance", ""), + ("ec.key.advancedspill", "ultraSettings/desaturate", ""), + ("ec.key.advancedspill", "ultraSettings/spillRange", ""), + ("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""), + ("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""), + ("ec.key.ccsimplewireremoval", "thickness", ""), + ("ec.key.keycleaner", "additionalEdgeRadius", ""), + ("ec.key.keycleaner", "alphaContrast", "percent"), + ("ec.matte.refinehard", "feather", ""), + ("ec.matte.refinehard", "increaseDecontaminationRadius", ""), + ("ec.matte.refinesoft", "smooth", ""), + ("ec.matte.refinesoft", "feather", ""), + ("ec.matte.refinesoft", "shiftEdge", "percent"), + ("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/feather", ""), + ("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/feather", ""), + ("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"), + ("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""), + ("ec.matte.simplechoker", "chokeMatte", "pixels"), + ("ec.noise.fractal", "transform/scale", "percent"), + ("ec.noise.fractal", "transform/scaleWidth", "percent"), + ("ec.noise.fractal", "transform/scaleHeight", "percent"), + ("ec.noise.fractal", "complexity", "percent"), + ("ec.noise.fractal", "subSettings/subInfluence", ""), + ("ec.noise.turbulent", "transform/scale", "percent"), + ("ec.noise.turbulent", "transform/scaleWidth", "percent"), + ("ec.noise.turbulent", "transform/scaleHeight", "percent"), + ("ec.noise.turbulent", "complexity", "percent"), + ("ec.noise.turbulent", "subSettings/subInfluence", ""), + ("ec.key.colorkey", "edgeFeather", ""), + ("ec.obsolete.lightning", "branchAngle", ""), + ("ec.key.luma", "edgeFeather", ""), + ("ec.blur.reduceflicker", "softness", ""), + ("ec.perspective.cameratracker", "trackPointSize", "percent"), + ("ec.perspective.cameratracker", "targetSize", "percent"), + ("ec.perspective.cccylinder", "lightHeight", "pixels"), + ("ec.sim.ccbubbles", "bubbleSize", "percent"), + ("ec.sim.ccdrizzle", "displacement", ""), + ("ec.sim.ccdrizzle", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "longevity", ""), + ("ec.sim.ccmrmercury", "blobInfluence", "percent"), + ("ec.sim.ccmrmercury", "light/lightHeight", "pixels"), + ("ec.sim.ccmrmercury", "shading/metal", ""), + ("ec.sim.ccparticlesystems2", "birthRate", ""), + ("ec.sim.ccparticlesystems2", "longevity", ""), + ("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"), + ("ec.sim.ccrainfall", "windVariation", ""), + ("ec.sim.ccrainfall", "opacity", ""), + ("ec.sim.ccsnowfall", "sizeVariation", ""), + ("ec.sim.ccsnowfall", "speedVariation", ""), + ("ec.sim.ccsnowfall", "windVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""), + ("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""), + ("ec.sim.ccsnowfall", "opacity", ""), + ("ec.sim.ccstarburst", "speed", "percent"), + ("ec.sim.ccstarburst", "size", "pixels"), + ("ec.stylize.ccblockload", "completion", ""), + ("ec.stylize.ccburnfilm", "burn", ""), + ("ec.stylize.cchextile", "smearing", ""), + ("ec.stylize.cckaleida", "size", "percent"), + ("ec.stylize.ccvignette", "pinHighlights", ""), + ("ec.stylize.cartoon", "detailThreshold", ""), + ("ec.stylize.cartoon", "fill/shadingSmoothness", ""), + ("ec.stylize.cartoon", "edge/edgeSoftness", ""), + ("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""), + ("ec.stylize.glow", "colorLoops", "percent"), + ("ec.stylize.roughenedges", "stretch", "pixels"), + ("ec.time.pixelmotionblur", "vectorDetail", ""), + ("ec.transition.blockdissolve", "blockWidth", "percent"), + ("ec.transition.blockdissolve", "blockHeight", "percent"), + ("ec.transition.ccgridwipe", "border", "pixels"), + ("ec.transition.ccgridwipe", "tiles", "percent"), + ("ec.transition.cclinesweep", "completion", ""), + ("ec.transition.ccscalewipe", "stretch", "pixels"), + ("ec.transition.venetian", "width", "percent"), + ("ec.transition.venetian", "feather", ""), + ("ec.utility.cineon", "highlightRolloff", ""), + ("ec.utility.facemeasurements", "headOrientationX", "degrees"), + ("ec.utility.facemeasurements", "headOrientationY", "degrees"), + ("ec.utility.facemeasurements", "headOrientationZ", "degrees"), + ("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetX", "percent"), + ("ec.utility.facemeasurements", "mouthOffsetY", "percent"), +]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 223df7d15..9dcae4837 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod guard; pub mod history; pub mod jobs; mod keyframe_text; +mod keyframe_text_effects; pub mod learn; pub mod links; pub mod logging; diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 6de5e54be..30d3b436e 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -252,6 +252,31 @@ fn effect_properties_have_after_effects_indices() { } } +#[test] +fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() { + // After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no + // unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color + // sliders start at #10 and are percent. + let (mut s, l) = session(); + for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props( + &mut s, + l, + &["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"], + ); + for block in [ + "Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n", + "Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n", + "Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n", + "Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n", + "Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n", + ] { + assert!(text.contains(block), "{block}in\n{text}"); + } +} + #[test] fn mask_properties_are_written_as_after_effects_does() { // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not From d4f06b5a9d6a9828b841315a03b1dcab8396e328 Mon Sep 17 00:00:00 2001 From: Yura Izmailov Date: Tue, 20 Oct 2026 20:43:14 +0200 Subject: [PATCH 0258/1386] UI-FONT: cover the Chinese interface catalog with system fallback faces --- crates/ui-egui/src/i18n.rs | 2 ++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/theme.rs | 12 ++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b51..ce22433be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -10,6 +10,8 @@ pub(crate) mod effects; /// Translations for the panels, tooltips and dialogs below the menu bar (see the module docs). mod ui; use effectcraft_engine::menus::{MenuEntry, MenuNode}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79c..79a8c8a52 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -60,6 +60,13 @@ fn catalog(language: &str) -> Option<&'static Index> { Some(index.get_or_init(|| rows.iter().copied().collect())) } +/// Characters required by the fixed panel catalog, for system-font coverage selection. +/// Sorted and deduplicated so fallback registration is deterministic. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn catalog_characters(language: &str) -> Vec { + catalog(language).into_iter().flat_map(|c| c.values()).flat_map(|s| s.chars()).collect::>().into_iter().collect() +} + /// Translate one interface string: the current language's row for `text`, or `text` itself. pub(crate) fn tr(text: &str) -> &str { catalog(current()).and_then(|c| c.get(text)).copied().unwrap_or(text) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..7f9018fc7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -408,7 +408,14 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); let mut seen = Vec::new(); - for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { + // A font covering one Han probe may be a sparse subset. Register additional faces + // needed by the actual interface catalog instead of assuming whole-script coverage. + let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() }; + let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect(); + for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) { + if name == "catalog-system" && seen.iter().any(|&id| fonts::face(id).has_char(probe)) { + continue; + } let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue; @@ -422,7 +429,8 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Use the already-read, parsed bytes rather than reading a font file twice. let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; - family_fonts.font_data.insert(name.to_string(), Arc::new(data)); + let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() }; + family_fonts.font_data.insert(name.clone(), Arc::new(data)); for family in family_fonts.families.values_mut() { family.push(name.to_string()); } From 798b8a98e6911db3ef6a56b5a484bc72d0f062ee Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:31:13 +0200 Subject: [PATCH 0259/1386] Fix panic in path::rect when both size components are NaN Found while testing crates/path/src/lib.rs:136 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/path/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 249eb9fdd..5069cb83e 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -133,7 +133,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec { /// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right). pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs())); - let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0); + let max_rad = size[0].abs().min(size[1].abs()) / 2.0; + // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness. + let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) }; if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) } } @@ -354,6 +356,11 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec { mod tests { use super::*; + #[test] + fn rect_with_nan_size_does_not_panic() { + let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0); + } + #[test] fn roundtrip_vertex_model() { let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0); From 28f97be7ee259a3d16e4baa897d7cc1d0e758930 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:39:20 +0200 Subject: [PATCH 0260/1386] Fix integer overflow in sampleImage for huge sample points Found while testing crates/expr/src/sample.rs:102 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/expr/src/sample.rs | 4 ++-- crates/expr/src/tests.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs index bff5a2149..28f830bb2 100644 --- a/crates/expr/src/sample.rs +++ b/crates/expr/src/sample.rs @@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2], }; let mut acc = [0.0f64; 4]; let mut n = 0.0; - for y in y0..=y1.min(y0 + 4096) { - for x in x0..=x1.min(x0 + 4096) { + for y in y0..=y1.min(y0.saturating_add(4096)) { + for x in x0..=x1.min(x0.saturating_add(4096)) { let p = b.img.get(x, y); for k in 0..4 { acc[k] += p[k] as f64; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 18b725084..7d2790255 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() { assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0); } +/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds. +#[test] +fn sample_image_huge_point_is_transparent() { + let f = fx(); + assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0); +} + fn data_item(p: &mut Project, name: &str, text: &str) { let f = effectcraft_project::Footage { path: format!("/data/{name}"), From 915ba28a9d4a4734a314f887a7148a069ab7b0ec Mon Sep 17 00:00:00 2001 From: ded Date: Wed, 21 Oct 2026 10:35:47 +0300 Subject: [PATCH 0261/1386] Motion Tile's Output size works on adjustment layers (#1727) On an adjustment layer Motion Tile kept the comp-sized buffer whatever the Output Width / Height, and the adjustment draw dropped any stack result whose size differed from the canvas. After Effects grows (or crops) the comp rectangle there as on any layer, so a Transform after it brings the extra tiles into view, as in the frames attached to #1727 and #1409. - motion_tile_frame: one output frame for the CPU and GPU effect; on an adjustment it grows about the comp rectangle on whole pixels, keeps the buffer at 100 % and past 16 384 pixels a side. - adjustment_crop: the CPU and GPU adjustment draws put a stack result whose frame changed back onto the canvas instead of dropping it (Enc::crop on the GPU). Tests: the #1727 After Effects frames (910 x 340 % and 90 x 340 % then Scale 12 %), tiles past the frame and cropping on CPU, GPU vs CPU for adjustment and full-frame layers. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 37 ++++++ crates/effects/src/stylize2.rs | 8 +- crates/gpu/src/context.rs | 18 +++ crates/gpu/src/fx_stylize.rs | 8 +- crates/gpu/src/tests_fx_stylize.rs | 41 ++++++ crates/gpu/src/walk.rs | 6 +- crates/render/src/lib.rs | 8 ++ crates/render/src/tests_motion_tile.rs | 169 +++++++++++++++++++++++++ 8 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 crates/render/src/tests_motion_tile.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..aa11c4218 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -297,6 +297,43 @@ impl Buf { } } +/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: +/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its +/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp +/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view, +/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass +/// 16 384 pixels a side does too. +pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) { + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight")); + let ow = (lw * pw / 100.0).max(1.0 / scale); + let oh = (lh * ph / 100.0).max(1.0 / scale); + let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0)); + let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale]; + if !ctx.adjustment { + return (nw as u32, nh as u32, grow); + } + // The grown rectangle's top-left in the buffer below, on whole pixels so the result lines + // up with the comp again when the stack ends. + let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round()); + let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0; + if (pw == 100.0 && ph == 100.0) || !fits { + return (size.0, size.1, offset); + } + (nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0]) +} + +/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas +/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0). +/// `None` when it already is the canvas, frame for frame. +pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> { + let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round()); + if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) { + return None; + } + Some((dx as i64, dy as i64)) +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index b957b4e41..e0354ea75 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); let mut out = Image::new(nw, nh); let src = &b.img; out.rows_mut().for_each(|(y, row)| { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..7f34d4b75 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1246,6 +1246,24 @@ impl<'g> Enc<'g> { ); } + /// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent + /// where `src` doesn't reach (Image::crop). + pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage { + let dst = self.image(w, h); + let span = |o: i64, n: u32, len: u32| { + let (a, b) = (o.max(0), (o + n as i64).min(len as i64)); + (b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32)) + }; + let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst }; + let enc = self.encoder(); + enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 }, + ); + dst + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index f5b7708d9..d53367e76 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); if !e.g.fits(nw, nh) { return None; } diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 44e156aec..0829fa3f4 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -88,6 +88,8 @@ fn tiles_kaleida_repetile() { "ec.stylize.motiontile", &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], ); + // Output under 100 % on an adjustment crops the comp below (#1727). + effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]); effect_case("ec.stylize.cckaleida", &[]); effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); @@ -224,3 +226,42 @@ fn transform_motion_blur() { check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); } } + +/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the +/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes +/// back onto the canvas as on the CPU (#1727). +#[test] +fn adjustment_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(adj); + let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// The same on a full-frame footage layer (#1991). +#[test] +fn layer_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(97, 61); + s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(l); + let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b1209070a..81f30d4f5 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -357,7 +357,11 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv }; let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); r.run_effects_on(ctx, layer, true, &mut fx); - let (mut adjusted, _, _) = fx.finish()?; + let (mut adjusted, offset, _) = fx.finish()?; + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = effectcraft_effects::adjustment_crop(offset, [0.0; 2], (adjusted.width, adjusted.height), (w, h)) { + adjusted = e.crop(&adjusted, dx, dy, w, h); + } if adjusted.width != w || adjusted.height != h { return Some(()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..7897cb581 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2043,6 +2043,12 @@ impl<'a> Renderer<'a> { color::convert(&mut below.img, &c); } let mut adjusted = self.apply_effects(ctx, layer, below, true); + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = + effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height)) + { + adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height); + } if let Some(c) = self.pipe.to_blend() { color::convert(&mut adjusted.img, &c); } @@ -2420,6 +2426,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_motion_tile; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/tests_motion_tile.rs b/crates/render/src/tests_motion_tile.rs new file mode 100644 index 000000000..7d1645a02 --- /dev/null +++ b/crates/render/src/tests_motion_tile.rs @@ -0,0 +1,169 @@ +//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a +//! Transform after it that scales the tiled result into view (#1727, #1991). + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l +} + +/// A white frame with a red 20×20 square in its top-left corner. +fn picture(p: &mut Project, comp: &Comp) -> Vec { + vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])] +} + +/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`. +fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) { + let size = [W as f64, H as f64]; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]), + ("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); + } +} + +fn comp() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +/// The picture under an adjustment layer carrying the two effects. +fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]); + adj.switches.adjustment = true; + tile_then_scale(&mut p, &mut adj, out, scale); + let mut layers = vec![adj]; + layers.extend(picture(&mut p, &comp)); + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +/// The picture as a full-frame precomp layer carrying the two effects. +fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let layers = picture(&mut p, &inner); + p.comp_mut(iid).unwrap().layers = layers; + let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); + tile_then_scale(&mut p, &mut pre, out, scale); + p.comp_mut(cid).unwrap().layers.push(pre); + (p, cid) +} + +fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) { + let got = img.get(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}"); +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0; 4]; +const CLEAR: [f32; 4] = [0.0; 4]; + +/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame +/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and +/// the frame is covered edge to edge, the tiles from beyond the frame included. +fn assert_tiled(img: &Image, what: &str) { + for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] { + is(img, x, y, RED, what); + } + for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] { + is(img, x, y, WHITE, what); + } +} + +#[test] +fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = adjustment_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %"); +} + +#[test] +fn layer_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = layer_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %"); +} + +/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent +/// (After Effects shows the comp background there). +#[test] +fn adjustment_motion_tile_output_under_100_crops_the_frame() { + let (p, cid) = adjustment_scene(50.0, 100.0); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + is(&img, 100, 50, WHITE, "inside the kept centre"); + is(&img, 60, 30, WHITE, "inside the kept centre"); + for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] { + is(&img, x, y, CLEAR, "outside the kept centre"); + } +} + +/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with +/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540). +/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540): +/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465. +#[test] +fn adjustment_motion_tile_matches_the_after_effects_frames() { + for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]); + adj.switches.adjustment = true; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]), + ( + "ec.distort.transform", + vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))], + ), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + adj.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.comp_mut(cid).unwrap().layers = vec![adj, red]; + // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). + let img = render_frame(&p, cid, Tick::ZERO, 0.25); + let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); + let what = format!("Output {out_w} %"); + for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] { + assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red"); + } + for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] { + assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty"); + } + if x1 < 1920.0 { + assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column"); + } + } +} From c839ae7598ae19481ab6e4a40fdb4813b0f4e92a Mon Sep 17 00:00:00 2001 From: Nikolai Nyegaard Date: Wed, 21 Oct 2026 12:01:47 +0200 Subject: [PATCH 0262/1386] Settings: list the languages alphabetically by their own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones - The list used the registry order, which is the order the languages were added --- crates/engine/src/prefs.rs | 16 +++++++++------- crates/ui-egui/src/i18n.rs | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a2ce579ea..a6978135d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -26,16 +26,18 @@ pub const PREFS_FILE: &str = "prefs.json"; pub const LANGUAGES: &[(&str, &str)] = &[ ("Match System", "system"), ("English", "en"), + // Alphabetical by the language's own name after English; Cyrillic, then CJK names follow the + // Latin ones. + ("Čeština", "cs"), + ("Deutsch", "de"), + ("Español", "es"), + ("Français", "fr"), + ("Português (Brasil)", "pt-br"), + ("Русский", "ru"), + ("Українська", "uk"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant"), - ("Українська", "uk"), - ("Español", "es"), - ("Português (Brasil)", "pt-br"), - ("Русский", "ru"), - ("Français", "fr"), - ("Deutsch", "de"), - ("Čeština", "cs"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b51..ba7d73ad2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,7 +13,7 @@ use effectcraft_engine::menus::{MenuEntry, MenuNode}; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; +const CATALOG_CODES: [&str; 10] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). From 89d954a10b72660baaf65cd005f7578ccf7deaeb Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Wed, 21 Oct 2026 13:06:40 +0200 Subject: [PATCH 0263/1386] fix(effects): calibrate CC Blobbylize to the AE oracle Use the measured Softness kernel (sigma = 0.42 * Softness), threshold the blurred height with a ~1 px antialias, and allow Cut Away 100 to empty the matte. Split the source-matte displacement from the height-field RGB refraction: the matte uses a z=1 bevel normal fitted to the T2 step edges, while RGB follows the measured z=height refraction. Align the documented AE defaults and add CPU regression tests for the cut threshold, Cut Away 100, and the inward matte warp. Update the GPU kernel and parameters to match, keeping CPU/GPU parity. --- crates/effects/src/distort3.rs | 150 ++++++++++++++++++---- crates/gpu/src/fx_transition.rs | 6 +- crates/gpu/src/shaders/fx_transition.wgsl | 47 +++++-- 3 files changed, 159 insertions(+), 44 deletions(-) diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 99139483f..ca6e5ba83 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -107,7 +107,14 @@ fn norm3(v: [f64; 3]) -> [f64; 3] { [v[0] / l, v[1] / l, v[2] / l] } -fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { +/// f32 normal/dot helpers for the CC Blobbylize lighting pass. The GPU kernel runs in f32; +/// matching its precision here keeps specular highlights within the CPU/GPU parity tolerance. +fn norm3f(v: [f32; 3]) -> [f32; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } @@ -115,14 +122,17 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let soft = ctx.params.f("softness").max(0.0) * b.scale; - let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32; + let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32; let blob = layer_or_self(ctx, &b, "blobLayer", true, false); let prop = src_at(ctx.params.e("property")); let raw = Plane::from_image(&blob, |px| { let (c, a) = unpremul(px); pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a } }); - let h = gauss_plane(&raw, soft, soft); + // Oracle (AE 26.2, 16 bpc): a step thresholded at Cut 25/75 with Softness 20 moves the + // 50% contour by 5.55 px. For a Gaussian that is 0.67449 sigma, so sigma = 0.42 Softness. + // The same probe shows the matte stays a ~1 px antialiased threshold, not a broad ramp. + let h = gauss_plane(&raw, soft * 0.42, soft * 0.42); let intensity = ctx.params.f("lightIntensity") / 100.0; let lc = ctx.params.color("lightColor"); let point = ctx.params.e("lightType") == 1; @@ -135,37 +145,69 @@ fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let rough = ctx.params.f("roughness").clamp(0.001, 1.0); let metal = (ctx.params.f("metal") / 100.0) as f32; let shininess = (1.0 / rough).min(500.0); - let depth = (soft * 0.5).max(1.0); let src = b.img.clone(); let (w, hh) = (b.img.width as usize, b.img.height as usize); let span = (w.max(hh) as f64).max(1.0); + + // The source matte and the RGB refraction are separate in the oracle: + // - the matte edge of a self step moves 5/9/14/18 px at Softness 5/10/20/40. A normal + // with z = 1, a ~30 px depth at 1920 and a slope scale of 1.5 Softness reproduces that; + // - RGB follows a height-field refraction with z = height: on a linear ramp the measured + // horizontal offset is dx = -0.34 / h, independent of Softness. + let matte_slope = soft * 1.5; + let matte_depth = if soft > 0.05 { span / 64.0 } else { 0.0 }; + let rgb_depth = span * 0.34 * b.scale; + let light_slope = (soft * 0.65).max(1.0); + let limit = span * 0.5; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (xi, yi) = (x as i64, y as i64); let hv = h.get(x, y); let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; - // Refraction-like displacement down the slope, then the blobby matte. - let sx = x as f64 + 0.5 - gx * depth * 2.0; - let sy = y as f64 + 0.5 - gy * depth * 2.0; - let s = src.sample_bilinear(sx, sy); - let (c, a) = unpremul(s); - let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0); - let alpha = a.max(s[3]) * m; + let grad = (gx * gx + gy * gy).sqrt(); + // One-pixel antialiased threshold. A flat field is either fully in or fully out. + let aa = (1.25 * grad).max(5e-4); + let m = if grad > 1e-9 { + (0.5 + (hv - cut) as f64 / aa).clamp(0.0, 1.0) as f32 + } else if hv > cut { + 1.0 + } else { + 0.0 + }; + if m <= 0.0 { + *px = [0.0; 4]; + continue; + } + // Warp the source matte with the softness-scaled bevel normal (z = 1). + let na = norm3([-gx * matte_slope, -gy * matte_slope, 1.0]); + let sa = src.sample_bilinear(x as f64 + 0.5 + na[0] * matte_depth, y as f64 + 0.5 + na[1] * matte_depth); + let alpha = sa[3].max(0.0) * m; if alpha <= 0.0 { *px = [0.0; 4]; continue; } - let n = norm3([-gx * depth, -gy * depth, 1.0]); - let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir }; - let ndl = dot3(n, l).max(0.0); - let hv3 = norm3([l[0], l[1], l[2] + 1.0]); - let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular; + // Refract the colour through the height field (z = height for this pass). + let hz = (hv as f64).max(1e-3); + let rx = (-rgb_depth * gx / hz).clamp(-limit, limit); + let ry = (-rgb_depth * gy / hz).clamp(-limit, limit); + let srgb = src.sample_bilinear(x as f64 + 0.5 + rx, y as f64 + 0.5 + ry); + let (c, _) = unpremul(srgb); + let (gx32, gy32) = (gx as f32, gy as f32); + let n = norm3f([-gx32 * light_slope as f32, -gy32 * light_slope as f32, 1.0]); + let l = if point { + norm3f([lpos.0 as f32 - (x as f32 + 0.5), lpos.1 as f32 - (y as f32 + 0.5), (lheight / 100.0 * span) as f32]) + } else { + [ldir[0] as f32, ldir[1] as f32, ldir[2] as f32] + }; + let ndl = dot3f(n, l).max(0.0); + let hv3 = norm3f([l[0], l[1], l[2] + 1.0]); + let sp = dot3f(n, hv3).max(0.0).powf(shininess as f32) * specular as f32; let mut out = [0.0f32; 3]; for i in 0..3 { - let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i]; + let lit = (ambient + diffuse * ndl as f64 * intensity) as f32 * lc[i]; let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i]; - out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col; + out[i] = c[i] * lit + (sp as f64 * intensity) as f32 * spec_col; } *px = premul(out, alpha); } @@ -880,19 +922,19 @@ pub fn specs() -> Vec { "CC Blobbylize", vec![ p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), - p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])), - p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)), - p("cutAway", "Cut Away", num(0.0), pct()), - p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("property", "Property", Value::Enum(0), popup(&crate::util::SRC_NAMES[..8])), + p("softness", "Softness", num(20.0), slider(0.0, 500.0, 0.0, 50.0, 1)), + p("cutAway", "Cut Away", num(25.0), pct()), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), - p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), - p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightHeight", "Light Height", num(65.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point), p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), - p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)), - p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("ambient", "Ambient", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(25.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.025), slider(0.001, 1.0, 0.001, 1.0, 3)), p("metal", "Metal", num(100.0), pct()), ], cc_blobbylize, @@ -1127,6 +1169,58 @@ mod tests { } } + #[test] + fn blobbylize_cut_away_thresholds_a_height_ramp() { + // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(25.0))], im, &[]); + assert_eq!(o.img.get(400, 4)[3], 0.0, "below Cut Away must be transparent"); + assert_eq!(o.img.get(560, 4)[3], 1.0, "above Cut Away must be opaque"); + let edge = o.img.get(480, 4)[3]; + assert!((0.45..=0.55).contains(&edge), "edge alpha {edge} is not a one-pixel threshold"); + let partial = (450..510).filter(|&x| (0.001..0.999).contains(&o.img.get(x, 4)[3])).count(); + assert!(partial <= 2, "{partial} antialiased columns instead of one"); + } + + #[test] + fn blobbylize_cut_away_100_is_empty() { + // Regression: the old clamp to 0.99 left the top of the ramp opaque at Cut Away 100. + let im = crate::util::gen_image(1920, 8, |x, _| { + let h = x as f32 / 1920.0; + [h, h, h, 1.0] + }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(0)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels"); + } + + #[test] + fn blobbylize_softness_warps_a_step_matte_inward() { + // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte + // edge at about x = 946, not at the source edge. The PR's matte-preserving version + // left it at 960; the effect must warp the source matte. + let im = crate::util::gen_image(1920, 32, |x, _| if x < 960 { [1.0; 4] } else { [0.0; 4] }); + let o = run( + "ec.distort.ccblobbylize", + &[ + ("property", Value::Enum(3)), + ("softness", num(20.0)), + ("cutAway", num(0.0)), + ("ambient", num(100.0)), + ("diffuse", num(0.0)), + ("specular", num(0.0)), + ("metal", num(0.0)), + ], + im, + &[], + ); + assert!(o.img.get(945, 16)[3] > 0.95, "the matte did not move inward: {}", o.img.get(945, 16)[3]); + assert!(o.img.get(949, 16)[3] < 0.05, "source alpha leaked past the warped edge"); + assert_eq!(o.img.get(960, 16)[3], 0.0, "the source matte was preserved instead of warped"); + } + /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized /// (200 × 100) layer bounds, after `id` with `set`. fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index a0ad54658..60378491e 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -586,17 +586,19 @@ fn blobbylize(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let soft = pr.f("softness").max(0.0) * b.scale; let blob = layer_or_self(e, ctx, &b, "blobLayer")?; let raw = plane(e, &blob, pr.e("property").min(10)); - let h = gaussian_blur(e, &raw, soft, soft, true); + let h = gaussian_blur(e, &raw, soft * 0.42, soft * 0.42, true); let lpos = b.to_px(pr.v2("lightPosition")); let lheight = pr.f("lightHeight"); let (d, el) = (pr.f("lightDirection").to_radians(), (lheight / 100.0).clamp(0.0, 1.0) * std::f64::consts::FRAC_PI_2); let ldir = [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()]; let span = (b.img.width.max(b.img.height) as f64).max(1.0); let mut p = Params::default(); - p.f[0] = f4([(soft * 0.5).max(1.0), (pr.f("cutAway") / 100.0).clamp(0.0, 0.99), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0]); + p.f[0] = f4([(pr.f("cutAway") / 100.0).clamp(0.0, 1.0), pr.f("lightIntensity") / 100.0, pr.f("ambient") / 100.0, soft * 1.5]); p.f[1] = f4([pr.f("diffuse") / 100.0, pr.f("specular") / 100.0, (1.0 / pr.f("roughness").clamp(0.001, 1.0)).min(500.0), pr.f("metal") / 100.0]); p.f[2] = f4([lpos.0, lpos.1, lheight / 100.0 * span, (pr.e("lightType") == 1) as u32 as f64]); p.f[3] = f4([ldir[0], ldir[1], ldir[2], 0.0]); p.f[4] = rgb(pr.color("lightColor"), 0.0); + p.f[5] = f4([if soft > 0.05 { span / 64.0 } else { 0.0 }, span * 0.34 * b.scale, (soft * 0.65).max(1.0), 1.25]); + p.f[6] = f4([span * 0.5, 0.0, 0.0, 0.0]); run(e, "ftr_blob", &p, b, Some(&h), None) } diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 379b64469..ef5436338 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -569,9 +569,11 @@ fn ftr_bender(@builtin(global_invocation_id) gid: vec3) { } // ---------------------------------------------------------------- CC Blobbylize (distort3::cc_blobbylize) -// aux = the blurred blob plane (x). f[0] = (depth, cut away, intensity, ambient); -// f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, point light height, -// point light); f[3] = distant light direction; f[4] = light colour. +// aux = the Softness-blurred blob plane (x). f[0] = (cut away, intensity, ambient, +// matte slope); f[1] = (diffuse, specular, shininess, metal); f[2] = (light x, y, +// point light height, point); f[3] = distant light direction; f[4] = light colour; +// f[5] = (matte depth, RGB depth, light slope, matte antialias width); +// f[6].x = displacement clamp. @compute @workgroup_size(16, 16) fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { @@ -579,32 +581,49 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { if (!ftr_inside(p)) { return; } - let depth = P.f[0].x; - let cut = P.f[0].y; + let cut = P.f[0].x; let hv = textureLoad(aux, p, 0).x; let g = ftr_grad(p); + let grad = length(g); + var m = 0.0; + if (grad > 1e-9) { + let aa = max(P.f[5].w * grad, 5e-4); + m = clamp(0.5 + (hv - cut) / aa, 0.0, 1.0); + } else if (hv > cut) { + m = 1.0; + } + if (m <= 0.0) { + textureStore(out, p, vec4(0.0)); + return; + } let f = ftr_centre(p); - let s = sample_bilinear(src, f.x - g.x * depth * 2.0, f.y - g.y * depth * 2.0); - let c = ftr_unpremul(s); - let a = max(s.w, 0.0); - let m = clamp((hv - cut) / max((1.0 - cut) * 0.5, 1e-3), 0.0, 1.0); - let alpha = max(a, s.w) * m; + // Softness-scaled bevel warp of the source matte (normal z = 1). + let na = normalize(vec3(-g.x * P.f[0].w, -g.y * P.f[0].w, 1.0)); + let sa = sample_bilinear(src, f.x + na.x * P.f[5].x, f.y + na.y * P.f[5].x); + let alpha = max(sa.w, 0.0) * m; if (alpha <= 0.0) { textureStore(out, p, vec4(0.0)); return; } - let n = normalize(vec3(-g.x * depth, -g.y * depth, 1.0)); + // Height-field refraction of the colour (normal z = height), independent of Softness. + let hz = max(hv, 1e-3); + let d = -g * P.f[5].y / hz; + let lim = P.f[6].x; + let disp = vec2(clamp(d.x, -lim, lim), clamp(d.y, -lim, lim)); + let srgb = sample_bilinear(src, f.x + disp.x, f.y + disp.y); + let c = ftr_unpremul(srgb); + let n = normalize(vec3(-g.x * P.f[5].z, -g.y * P.f[5].z, 1.0)); var l = P.f[3].xyz; if (P.f[2].w != 0.0) { - l = normalize(vec3(P.f[2].x - f32(p.x), P.f[2].y - f32(p.y), P.f[2].z)); + l = normalize(vec3(P.f[2].x - f.x, P.f[2].y - f.y, P.f[2].z)); } - let intensity = P.f[0].z; + let intensity = P.f[0].y; let ndl = max(dot(n, l), 0.0); let h3 = normalize(vec3(l.x, l.y, l.z + 1.0)); let sp = powz(max(dot(n, h3), 0.0), P.f[1].z) * P.f[1].y; let lc = P.f[4].xyz; let metal = P.f[1].w; - let lit = (P.f[0].w + P.f[1].x * ndl * intensity) * lc; + let lit = (P.f[0].z + P.f[1].x * ndl * intensity) * lc; let spec_col = (1.0 - metal) * lc + metal * c * lc; let o = c * lit + (sp * intensity) * spec_col; textureStore(out, p, vec4(o * alpha, alpha)); From 6e85c762c145e5a5ecf36f0f86d7869a8f468f85 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Wed, 21 Oct 2026 13:33:53 +0200 Subject: [PATCH 0264/1386] test(effects): pin the AE defaults of CC Blobbylize --- crates/effects/src/distort3.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index ca6e5ba83..88be62e99 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1169,6 +1169,22 @@ mod tests { } } + #[test] + fn blobbylize_defaults_match_ae() { + let s = crate::find("ec.distort.ccblobbylize").unwrap(); + let default = |id: &str| s.params.iter().find(|p| p.id == id).unwrap().default.clone(); + assert_eq!(default("property"), Value::Enum(0), "Property defaults to Red"); + assert_eq!(default("softness"), num(20.0)); + assert_eq!(default("cutAway"), num(25.0)); + assert_eq!(default("lightHeight"), num(65.0)); + assert_eq!(default("lightPosition"), pt(0.5, 0.5)); + assert_eq!(default("ambient"), num(75.0)); + assert_eq!(default("diffuse"), num(25.0)); + assert_eq!(default("specular"), num(100.0)); + assert_eq!(default("roughness"), num(0.025)); + assert_eq!(default("metal"), num(100.0)); + } + #[test] fn blobbylize_cut_away_thresholds_a_height_ramp() { // AE oracle: Cut Away is a linear height threshold with a ~1 px antialiased edge. From fda7eef73fe6a73b047f285bef39bf2271d3844e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Wed, 21 Oct 2026 14:34:08 +0200 Subject: [PATCH 0265/1386] contributors: credit ki2pixel --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 625fcdb73..1012a37bd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -95,3 +95,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.ki2pixel] +display_name = "ki2pixel" From 374c3bf6b9db9a01ce25a93139fc11bbbf4efc06 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Wed, 21 Oct 2026 14:46:21 +0200 Subject: [PATCH 0266/1386] fix(effects): clear Cut Away 100 opaque edges The gradient antialias could leave a half-covered pixel where an opaque source reaches h = 1. Add an endpoint guard on CPU and GPU and cover an opaque edge in the regression test. --- crates/effects/src/distort3.rs | 15 +++++++++++++++ crates/gpu/src/shaders/fx_transition.wgsl | 6 ++++++ crates/gpu/src/tests_fx_transition.rs | 1 + 3 files changed, 22 insertions(+) diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 88be62e99..cb389d901 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -123,6 +123,12 @@ fn dot3f(a: [f32; 3], b: [f32; 3]) -> f32 { fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { let soft = ctx.params.f("softness").max(0.0) * b.scale; let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 1.0) as f32; + if cut >= 1.0 { + // Endpoint guard: at Cut Away 100 the gradient antialias must not leave a half-covered + // pixel where an opaque source reaches exactly 1.0. + b.img.data.fill([0.0; 4]); + return b; + } let blob = layer_or_self(ctx, &b, "blobLayer", true, false); let prop = src_at(ctx.params.e("property")); let raw = Plane::from_image(&blob, |px| { @@ -1212,6 +1218,15 @@ mod tests { assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left opaque pixels"); } + #[test] + fn blobbylize_cut_away_100_clears_an_opaque_edge() { + // An opaque source reaches h = 1 on the high side of an edge; the gradient antialias + // used to leave m = 0.5 there, so Cut Away 100 still produced visible pixels. + let im = crate::util::gen_image(64, 8, |x, _| if x < 32 { [1.0; 4] } else { [0.0; 4] }); + let o = run("ec.distort.ccblobbylize", &[("property", Value::Enum(3)), ("softness", num(0.0)), ("cutAway", num(100.0))], im, &[]); + assert!(o.img.data.iter().all(|p| p[3] == 0.0), "Cut Away 100 left an opaque edge"); + } + #[test] fn blobbylize_softness_warps_a_step_matte_inward() { // AE oracle T2: a self alpha step at x = 960, Softness 20, Cut Away 0 puts the matte diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index ef5436338..5037ec9a6 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -582,6 +582,12 @@ fn ftr_blob(@builtin(global_invocation_id) gid: vec3) { return; } let cut = P.f[0].x; + if (cut >= 1.0) { + // Endpoint guard: an opaque source can reach h = 1.0, where the gradient antialias + // would otherwise leave a half-covered pixel at Cut Away 100. + textureStore(out, p, vec4(0.0)); + return; + } let hv = textureLoad(aux, p, 0).x; let g = ftr_grad(p); let grad = length(g); diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs index b4b3572a0..abe9273dc 100644 --- a/crates/gpu/src/tests_fx_transition.rs +++ b/crates/gpu/src/tests_fx_transition.rs @@ -126,6 +126,7 @@ fn distort() { "ec.distort.ccblobbylize", &[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))], ); + effect_case("ec.distort.ccblobbylize", &[("cutAway", n(100.0))]); effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]); } From d2cb0946efed13b02d1f484413e5dd964efcb771 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Wed, 21 Oct 2026 16:20:44 +0200 Subject: [PATCH 0267/1386] test(gpu): assert Cut Away 100 is fully transparent --- crates/gpu/src/tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..1fad0e6cd 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -446,12 +446,16 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } + let expect_empty = id == "ec.distort.ccblobbylize" && vals.iter().any(|(k, v)| *k == "cutAway" && matches!(v, Value::Scalar(x) if *x == 100.0)); for scale in [1.0, 0.5] { let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() }; let cpu = (spec.render)(&ctx(), buf.clone()); let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + if expect_empty { + assert!(out.img.data.iter().all(|p| p[3] == 0.0), "{id} {vals:?} adj {adjustment} scale {scale}: Cut Away 100 produced non-zero alpha"); + } assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry"); let d = diff(&cpu.img, &out.img, 1e-3); let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 }; From 5668196288e34f7ef1969ba51b2c38950ccbd634 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Wed, 21 Oct 2026 20:57:08 +0530 Subject: [PATCH 0268/1386] docs: escape the backtick in the twirlSelected shortcut (#2025) --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4d01ef2e9..6cfbcd8db 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params | `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. | | `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). | | Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. | -| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.` / `timeline.revealAdd.` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). | +| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.` / `timeline.revealAdd.` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` ``(Ctrl/Cmd+`)``, `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). | | `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` | | `ui.menu.list` | `{}` | The menu tree with enablement. | | `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: ""}` and writes no file. | From 434258050ac1642eb19051857be23e86a56d00f6 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:00:20 +0900 Subject: [PATCH 0269/1386] Text: Chinese Han text uses the installed Chinese face (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS does not install the on-demand PingFang SC (macOS 27 on an English install ships Hiragino Sans GB, Heiti SC, Songti SC and STSong instead), and the Simplified Chinese fallback list named only Windows and Linux families. A Chinese character therefore fell through to the exhaustive search, which picks the first installed face that covers it — Hiragino Sans, the Japanese face — so Chinese text in a composition was drawn with Japanese glyph forms (#453). Japanese kana and kanji were already handled, and a Chinese-only character the Japanese face lacks (e.g. 页) still found a Chinese face, which is why the symptom looked selective. Add the macOS Chinese faces to the fallback lists: Hiragino Sans GB, Heiti SC, Songti SC and STSong for Simplified Chinese, Songti TC for Traditional (Heiti TC was already named). The interface language still chooses between the scripts and an English interface keeps the Japanese-first order for shared Han ideographs, as designed in #312; what changes is that a Chinese-locale request has an installed Chinese family to answer it on macOS. Tests: a text-layer render test draws Japanese and Chinese text and checks glyph coverage (skipped where no CJK face is installed); a layout test checks every CJK glyph comes from a face that has the character, so a missing-glyph box cannot creep back; and the fonts test fails without the list change ("Hiragino Sans GB missing from the Han list"), passes with it, and walks the Han branch's list to prove the first installed Simplified family is a Chinese face, not a Japanese one. Verified with CARGO_BUILD_JOBS=2 RUST_TEST_THREADS=2: effectcraft-text 64 passed; effectcraft-render 233 passed, 1 ignored; cargo clippy -p effectcraft-text -p effectcraft-render --all-targets -- -D warnings clean; cargo fmt --all -- --check clean. A 900x240 render of "骨直真具画 中文测试" with the zh-hans request now uses Hiragino Sans GB and differs from the Japanese render, which still uses Hiragino Sans. --- ROADMAP.md | 8 ++++++- crates/render/src/tests.rs | 21 ++++++++++++++++ crates/text/src/fonts.rs | 48 ++++++++++++++++++++++++++++++++++--- crates/text/src/layout.rs | 22 +++++++++++++++++ docs/gaps.md | 5 ++-- docs/localization-parity.md | 5 ++-- docs/roadmap.md | 5 ++-- docs/target-app-parity.md | 7 +++--- 8 files changed, 108 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..4b2fbfe00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Simplified Chinese Han text in compositions reaches the installed macOS Chinese faces instead of the Japanese face, with regression tests, #453; previous: minor, open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-22: Text: Chinese Han text in compositions now draws with the installed Chinese face + instead of the Japanese one. macOS does not install the on-demand PingFang SC, so the Simplified + Han list names Hiragino Sans GB, Heiti SC, Songti SC and STSong (Heiti TC and Songti TC for + Traditional) before the fallback search can reach a Japanese face (#453). Japanese kana and kanji + are unchanged; a text-layer render test and a macOS fallback test cover both languages. - 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 @@ -154,6 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese Han text in compositions uses the installed macOS Chinese faces instead of the Japanese face; CJK text-layer and macOS fallback regression tests (#453) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..7f09339fd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -219,6 +219,27 @@ fn text_layer_renders_glyphs() { assert!(covered > 300, "{covered}"); } +/// #453: a composition's Japanese (and Chinese) text layer draws its glyphs, not missing-glyph +/// boxes. Skipped where no CJK face is installed. +#[test] +fn text_layer_renders_cjk_glyphs() { + let cjk = effectcraft_text::fonts::resolve("Inter", "Regular").face; + if !effectcraft_text::fonts::face(effectcraft_text::fonts::fallback_for('日', cjk)).has_char('日') { + eprintln!("no Japanese face installed; skipping"); + return; + } + for text in ["日本語", "中文"] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: text.into(), size: 46.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([100.0, 70.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let covered = img.data.iter().filter(|px| px[3] > 0.5).count(); + assert!(covered > 200, "{text}: {covered} covered pixels"); + } +} + #[test] fn precomp_and_3d_render() { let (mut p, cid, comp) = setup(); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index aeabf1872..5e057811c 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -685,14 +685,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -1307,6 +1321,34 @@ mod tests { assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } + /// macOS ships no Simplified Chinese PingFang on an English install (macOS 27 has the + /// on-demand PingFang instead): Hiragino Sans GB, Heiti SC and Songti SC are the installed + /// Chinese faces, so the Han list has to name them or Han text falls through to the Japanese + /// face and draws Japanese glyph forms (#453). The system check is skipped where none of them + /// is installed; the list itself is checked everywhere. + #[test] + fn macos_chinese_faces_are_tried_for_han_text() { + for face in ["Hiragino Sans GB", "Heiti SC", "Songti SC"] { + assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case(face)), "{face} missing from the Han list"); + } + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case("Songti TC")), "Songti TC missing from the Traditional list"); + scan_system(); + let installed: Vec<&str> = ["Hiragino Sans GB", "Heiti SC", "Songti SC"].into_iter().filter(|f| resolve_in(f, "Regular").is_some()).collect(); + if installed.is_empty() { + eprintln!("no macOS Simplified Chinese face installed; skipping the system check"); + return; + } + // Walking the Han branch's own list finds a Chinese face before the Japanese face the + // exhaustive search would have found (Hiragino Sans), and it covers the shared ideographs. + let (family, resolved) = + SIMPLIFIED_CHINESE_FAMILIES.iter().find_map(|f| resolve_in(f, "Regular").map(|r| (*f, r))).expect("an installed Simplified Chinese family"); + assert!(installed.iter().any(|i| i.eq_ignore_ascii_case(family)), "{family} is not a macOS Chinese face"); + assert!(!JAPANESE_FAMILIES.iter().any(|j| j.eq_ignore_ascii_case(family)), "{family} is a Japanese family"); + for c in ['骨', '直', '日'] { + assert!(face(resolved.face).has_char(c), "{family} cannot draw {c}"); + } + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1ae..770afb965 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1284,6 +1284,28 @@ mod tests { assert!(l.glyphs.iter().all(|g| g.id != 0)); } + /// CJK text drawn with a bundled Latin family falls back to an installed face that has the + /// glyph, so a composition's Japanese or Chinese text is not drawn as missing-glyph boxes + /// (#453). Skipped where no CJK face is installed, like the font-fallback tests. + #[test] + fn cjk_text_falls_back_to_a_face_with_the_glyph() { + let primary = fonts::resolve("Inter", "Regular").face; + if !fonts::face(fonts::fallback_for('日', primary)).has_char('日') { + eprintln!("no Japanese face installed; skipping"); + return; + } + for text in ["日本語", "中文漢字"] { + let l = layout(text, &st(40.0), &ParagraphStyle::default()); + assert_eq!(l.glyphs.len(), text.chars().count(), "{text}"); + for g in &l.glyphs { + let ch = text.get(g.cluster..).and_then(|rest| rest.chars().next()).unwrap_or('?'); + assert_ne!(g.id, 0, "{ch} has a glyph"); + let f = fonts::face(g.face); + assert!(f.has_char(ch), "{ch} drawn by {} {} which lacks it", f.info.family, f.info.style); + } + } + } + #[test] fn empty_text_has_a_caret() { let l = layout("", &st(40.0), &ParagraphStyle::default()); diff --git a/docs/gaps.md b/docs/gaps.md index fd92c22a4..7c01c96c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (gap 11: the Chinese Han fallback reaches the installed macOS Chinese faces, #453; previous: minor, path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -67,7 +67,7 @@ are grouped by area; the issue tracker has the rest. | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | @@ -335,6 +335,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Gap 11: the Chinese Han fallback reaches the installed macOS Chinese faces, so Chinese text in compositions no longer draws Japanese glyph forms (#453) | | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) | | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..b99ad5884 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed Chinese faces, so Chinese text in compositions draws Chinese glyph forms, #453; previous: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -64,7 +64,7 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language and now reaches the installed Chinese faces (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | @@ -103,6 +103,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese text in compositions follows the fixed Han fallback: installed Chinese faces are tried before the Japanese face (#453) | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/roadmap.md b/docs/roadmap.md index 73fdfd235..e2692fd8b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed Chinese faces, #453; previous: major, new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 Forward-looking detail behind the [ROADMAP](../ROADMAP.md). The work list itself, one gap at a time, is [gaps.md](gaps.md); the numbers are in [target-app-parity.md](target-app-parity.md). @@ -31,7 +31,7 @@ none blocks the workflow. |---|---|---|---| | 1. Set up: new comp, import footage, stills, PSD/AI and audio, arrange and trim layers | yes | `file.import` and image-sequence tests, `ui_drag_drop`, `ui_project_*`; rough: a still imports as a sequence (#431), an MP3 layer can go silent (#324), multi-file drops (#552) | 2–4 | | 2. Animate: keyframes, easing in the Graph Editor, motion paths, parenting | yes | 168 live samples match After Effects 26.3 ([fidelity/](fidelity/README.md)); `ui_keyframes`; rough: Graph Editor jitter and clipped buttons (#456, #457), negative rotation drag (#569) | 3–6 | -| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), CJK text in comps (#453) | 3–6 | +| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), Chinese text in comps follows the Han fallback (#453 fixed) | 3–6 | | 4. Composite: masks, track mattes, blend modes, keying and effects | yes | Compositor and mask tests, 306 effects with CPU/GPU agreement; rough: mask bugs (#510, #513, #527), 32-bpc blend differences (#494), effect bugs (#580 crash, #514) | 6–12 | | 5. Preview with audio (RAM preview, cache) | yes | `ui_ram_preview`, `ui_live_preview`; rough: stutter on cached frames (#595, #304), late audio (#554, #504) | 10–20 | | 6. Save, reopen and deliver: `.ecproj`, auto-save, Render Queue to H.264 / ProRes / sequences | yes | Versioned-schema reopen tests, crash recovery, render-queue and encoder tests; rough: the macOS default output folder is `/`, so a render fails until an output path is chosen (#446), ProRes 4444 colour tag (#340), projects don't relink when moved | 1–3 | @@ -83,5 +83,6 @@ To beta (≈ 75% ready and `.aep` opening): **≈ 350–620 h**. To full parity: | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | The Chinese Han fallback reaches the installed Chinese faces (#453) | | 2026-10-10 | minor | Alpha gate: six core workflows checked; all pass, stage stays alpha | | 2026-10-10 | major | New file: milestones from ROADMAP.md, current focus, next steps with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa4524..a3ddc3bfc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with After Effects -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, catalog and export-format references re-audited against `upstream/main` 470ea7a3; the 10 October re-measure is otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed macOS Chinese faces, so Chinese text in compositions no longer draws Japanese glyph forms, #453; previous: minor, issue, catalog and export-format references re-audited against `upstream/main` 470ea7a3; the 10 October re-measure is otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) The authoritative assessment of how close EffectCraft is to After Effects, and how much work is left. [gaps.md](gaps.md) lists every known shortfall one at a time; the @@ -119,7 +119,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | -| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | +| Text and typography | 7% | 94% | 50% | 20–35 | Chinese text in comps now reaches the installed Chinese faces in the Han fallback (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | @@ -193,7 +193,7 @@ generative/cloud AI (Object Matte, Firefly), Adobe services and team features, s | Keyframes, Graph Editor, easing, motion paths | 20% | 75% | 168 live samples match After Effects; #456/#457, #553 and #569 were counted here and are now closed | | Expressions: `wiggle`, `loopOut`, linking, sliders | 6% | 80% | `crates/expr` tests; no live expression oracle yet | | Shape layers and masks | 12% | 65% | #510, #513, #527 mask bugs and #303 Mesh Warp were counted here and are now closed; open: #1004, #999, #1042 | -| Text and text animators | 12% | 70% | #316 alignment inverted, #453 CJK (open); #583 font search (closed) | +| Text and text animators | 12% | 70% | #316 alignment inverted; #453 CJK fallback fixed; #583 font search (closed) | | Everyday effects: blurs, glow, fill/tint, curves/levels, keying, drop shadow, displacement | 10% | 70% | All present with GPU paths; unmeasured against After Effects; #514 Apply Color LUT (closed) | | Preview (RAM preview with audio) | 8% | 55% | #595, #304 stutter; #504 late audio (#554 closed) | | Render to H.264 / ProRes / PNG sequences | 6% | 75% | Encoder tests; #340 ProRes 4444 tag (open); #446 default macOS output folder (closed) | @@ -1166,6 +1166,7 @@ top of this page and in items 16–18 above. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese Han text in compositions uses the installed macOS Chinese faces rather than the Japanese face (#453) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open crash list, closed-issue references in the area tables, export formats 12 to 13, localization ≈ 12% to ≈ 18%, `#include`, built-in preset count, 12 October repository counts | | 2026-10-10 | minor | Readiness-by-audience table: hours to ≈ 95% for each of the three numbers; the full number stays an additive weighted sum (unchanged) | | 2026-10-10 | minor | Added mainstream practitioner (≈ 47%) and essentials user (≈ 52%) numbers with weights, discounts and GitHub user evidence; wrote down how the full ready number is built (unchanged at ≈ 45%) | From ca88af2b5f271c5c8be324cbe0574e99ece07bf2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:34:45 +0530 Subject: [PATCH 0270/1386] Fractal Noise: resolve the Blending Mode once per layer, not per pixel `crates/effects/src/noise3.rs` only: - a `blend_mode(u32) -> Option` helper, which the CPU and GPU paths now share; - the CPU `render` resolves the mode once and passes `Option` to the per-pixel `composite`. The semantics are unchanged: None means the noise alone, and an unknown index means Normal. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/noise3.rs | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..0ea63dfe5 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -406,15 +406,20 @@ pub fn fractal_gpu(ctx: &EffectCtx, offset: [f64; 2], scale: f64, turbulent: boo sub_rotation: fr.sub_rotation != 0.0, norm, octaves, - mode: match ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE) { - BLEND_NONE => None, - m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), - }, + mode: blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)), opacity: (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0), blend: ctx.params.f("blend") as f32 / 100.0, } } +/// Resolve a Blending Mode index: None → `None`, unknown → Normal. +fn blend_mode(m: u32) -> Option { + match m { + BLEND_NONE => None, + m => Some(BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal)), + } +} + /// Map a value by an [`OVERFLOWS`] option. pub fn overflow(v: f32, mode: u32) -> f32 { match mode { @@ -431,14 +436,11 @@ pub fn overflow(v: f32, mode: u32) -> f32 { /// Composite grey noise `val` over the original pixel by Blending Mode, Opacity and (ours) Blend /// With Original. The noise stays inside the layer's alpha, so a masked solid shows it only within /// its masks (#939). -pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] { +pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: Option, blend_orig: f32) -> [f32; 4] { let src = [val * opacity, val * opacity, val * opacity, opacity]; let out = match mode { - BLEND_NONE => src, - m => { - let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal); - blend_pixel(bm, orig, src, 0.5) - } + None => src, + Some(bm) => blend_pixel(bm, orig, src, 0.5), }; let k = 1.0 - blend_orig; [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k) @@ -447,7 +449,7 @@ pub(crate) fn composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend fn render(ctx: &EffectCtx, mut b: Buf, turbulent: bool) -> Buf { let fr = Fractal::from_params(ctx, &b, turbulent); let opacity = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0); - let mode = ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE); + let mode = blend_mode(ctx.params.get("blendingMode").map(Value::as_enum).unwrap_or(BLEND_NONE)); let blend = ctx.params.f("blend") as f32 / 100.0; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { @@ -665,4 +667,34 @@ mod tests { } assert!(axis / diag < 1.15, "gradient along the axes / along the diagonals: {}", axis / diag); } + + /// The base composite, resolving the Blending Mode by name on every call, kept only to prove + /// the hoisted lookup changes nothing. + fn old_composite(orig: [f32; 4], val: f32, opacity: f32, mode: u32, blend_orig: f32) -> [f32; 4] { + let src = [val * opacity, val * opacity, val * opacity, opacity]; + let out = match mode { + BLEND_NONE => src, + m => { + let bm = BLEND_MODES.get(m as usize).and_then(|l| BlendMode::from_name(l)).unwrap_or(BlendMode::Normal); + blend_pixel(bm, orig, src, 0.5) + } + }; + let k = 1.0 - blend_orig; + [0, 1, 2, 3].map(|c| orig[c] * blend_orig + out[c] * orig[3] * k) + } + + #[test] + fn resolved_blend_matches_per_pixel_lookup() { + for m in (0..BLEND_MODES.len() as u32).chain([24, 999]) { + for orig in [[0.2, 0.4, 0.6, 1.0], [0.9, 0.1, 0.5, 0.5], [0.0, 0.0, 0.0, 0.0]] { + for val in [0.0, 0.3, 0.75, 1.0] { + for (op, bl) in [(1.0, 0.0), (0.6, 0.25)] { + assert_eq!(composite(orig, val, op, blend_mode(m), bl), old_composite(orig, val, op, m, bl), "mode {m}"); + } + } + } + } + assert_eq!(blend_mode(BLEND_NONE), None); + assert_eq!(blend_mode(999), Some(BlendMode::Normal)); + } } From c9323afe1dfc3dac8fdf0954706dac65f4e51bf8 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:34:59 +0530 Subject: [PATCH 0271/1386] Save: check the file's values without reparsing it in release builds - New `crates/project/src/file_check.rs`: a no-output `Serializer` that walks the value once. It refuses non-finite numbers and nesting past 127 levels, and it allocates only on error. - `to_file_json_with_view` runs that check, then writes the file. It also refuses a project schema newer than this build, the remaining thing `from_json` would reject in a file we wrote. - **Debug builds still reparse the text as well**, so a new kind of mismatch still shows up in tests. The bytes written are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/project/src/file_check.rs | 329 +++++++++++++++++++++++++++++++ crates/project/src/lib.rs | 19 +- crates/project/src/tests.rs | 23 +++ 3 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 crates/project/src/file_check.rs diff --git a/crates/project/src/file_check.rs b/crates/project/src/file_check.rs new file mode 100644 index 000000000..f1b261c5a --- /dev/null +++ b/crates/project/src/file_check.rs @@ -0,0 +1,329 @@ +//! A cheap check that a project file will read back, without parsing it. +//! +//! `serde_json` writes a non-finite float as `null` and refuses to parse nesting deeper than 128 +//! containers, so a project holding either would be saved to a file that can't be opened. Walking +//! the value once, writing nothing, finds both without building the project again. + +use std::fmt; + +use serde::ser::{ + self, Serialize, SerializeMap, SerializeSeq, SerializeStruct, SerializeStructVariant, SerializeTuple, SerializeTupleStruct, SerializeTupleVariant, + Serializer, +}; + +/// serde_json refuses to parse the 128th nested array/object (its recursion limit is 128). +const MAX_DEPTH: u32 = 127; + +const NOT_FINITE: &str = "a number that isn't finite (NaN or infinity)"; +const TOO_DEEP: &str = "nesting deeper than 127 levels"; + +/// Why a value can't be written to a project file. +#[derive(Debug)] +pub(crate) struct Unstorable(String); + +impl fmt::Display for Unstorable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Unstorable {} + +impl ser::Error for Unstorable { + fn custom(msg: T) -> Self { + Unstorable(msg.to_string()) + } +} + +/// Walk `value` as `serde_json` would write it, producing no text and allocating only on error. +pub(crate) fn check(value: &T) -> Result<(), Unstorable> { + let mut walker = Walker { depth: 0 }; + value.serialize(&mut walker) +} + +/// The current nesting depth of the value being walked. +struct Walker { + depth: u32, +} + +impl Walker { + /// Enter `levels` nested containers, refusing anything serde_json couldn't parse back. + fn enter(&mut self, levels: u32) -> Result<(), Unstorable> { + let depth = self.depth.checked_add(levels).ok_or_else(|| Unstorable(TOO_DEEP.into()))?; + if depth > MAX_DEPTH { + return Err(Unstorable(TOO_DEEP.into())); + } + self.depth = depth; + Ok(()) + } + fn leave(&mut self, levels: u32) { + self.depth = self.depth.saturating_sub(levels); + } +} + +/// One compound value being walked; leaving it when it ends keeps the depth exact. +struct Compound<'a> { + walker: &'a mut Walker, + levels: u32, +} + +impl<'a> Compound<'a> { + fn new(walker: &'a mut Walker, levels: u32) -> Result { + walker.enter(levels)?; + Ok(Self { walker, levels }) + } + fn value(&mut self, value: &T) -> Result<(), Unstorable> { + value.serialize(&mut *self.walker) + } + fn finish(self) -> Result<(), Unstorable> { + self.walker.leave(self.levels); + Ok(()) + } +} + +impl SerializeSeq for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_element(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTuple for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_element(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTupleStruct for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeTupleVariant for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeMap for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_key(&mut self, key: &T) -> Result<(), Unstorable> { + self.value(key) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeStruct for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl SerializeStructVariant for Compound<'_> { + type Ok = (); + type Error = Unstorable; + fn serialize_field(&mut self, _key: &'static str, value: &T) -> Result<(), Unstorable> { + self.value(value) + } + fn end(self) -> Result<(), Unstorable> { + self.finish() + } +} + +impl<'a> Serializer for &'a mut Walker { + type Ok = (); + type Error = Unstorable; + type SerializeSeq = Compound<'a>; + type SerializeTuple = Compound<'a>; + type SerializeTupleStruct = Compound<'a>; + type SerializeTupleVariant = Compound<'a>; + type SerializeMap = Compound<'a>; + type SerializeStruct = Compound<'a>; + type SerializeStructVariant = Compound<'a>; + + fn serialize_bool(self, _v: bool) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i8(self, _v: i8) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i16(self, _v: i16) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i32(self, _v: i32) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i64(self, _v: i64) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_i128(self, _v: i128) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u8(self, _v: u8) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u16(self, _v: u16) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u32(self, _v: u32) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u64(self, _v: u64) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_u128(self, _v: u128) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_f32(self, v: f32) -> Result<(), Unstorable> { + if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) } + } + fn serialize_f64(self, v: f64) -> Result<(), Unstorable> { + if v.is_finite() { Ok(()) } else { Err(Unstorable(NOT_FINITE.into())) } + } + fn serialize_char(self, _v: char) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_str(self, _v: &str) -> Result<(), Unstorable> { + Ok(()) + } + /// serde_json writes bytes as an array of numbers: one level of nesting. + fn serialize_bytes(self, _v: &[u8]) -> Result<(), Unstorable> { + Compound::new(self, 1)?.finish() + } + /// Writes text for a `Display` value; it is a scalar, not a container. + fn collect_str(self, _v: &T) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_none(self) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_some(self, value: &T) -> Result<(), Unstorable> { + value.serialize(&mut *self) + } + fn serialize_unit(self) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_unit_variant(self, _name: &'static str, _index: u32, _variant: &'static str) -> Result<(), Unstorable> { + Ok(()) + } + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> Result<(), Unstorable> { + value.serialize(self) + } + fn serialize_newtype_variant(self, _name: &'static str, _index: u32, _variant: &'static str, value: &T) -> Result<(), Unstorable> { + let mut compound = Compound::new(self, 1)?; + compound.value(value)?; + compound.finish() + } + fn serialize_seq(self, _len: Option) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple(self, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple_struct(self, _name: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_tuple_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 2) + } + fn serialize_map(self, _len: Option) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_struct(self, _name: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 1) + } + fn serialize_struct_variant(self, _name: &'static str, _index: u32, _variant: &'static str, _len: usize) -> Result, Unstorable> { + Compound::new(self, 2) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::check; + + #[test] + fn refuses_non_finite_numbers_anywhere() { + assert!(check(&1.5f64).is_ok()); + assert!(check(&vec![Some(1.0f64), None]).is_ok()); + assert!(check(&BTreeMap::from([("a".to_string(), 2.0f32)])).is_ok(), "finite floats in a map"); + assert!(check(&f64::NAN).is_err()); + assert!(check(&f64::INFINITY).is_err()); + assert!(check(&vec![Some(f64::NEG_INFINITY)]).is_err(), "inside an option in a list"); + assert!(check(&BTreeMap::from([("a".to_string(), f32::NAN)])).is_err(), "as a map value"); + } + + #[test] + fn depth_limit_matches_serde_json() { + for n in [127, 128] { + let mut v = serde_json::Value::Null; + for _ in 0..n { + v = serde_json::Value::Array(vec![v]); + } + let text = serde_json::to_string(&v).unwrap(); + let reads_back = serde_json::from_str::(&text).is_ok(); + assert_eq!(check(&v).is_ok(), reads_back, "depth {n} agrees with serde_json"); + assert_eq!(check(&v).is_ok(), n == 127, "depth {n}"); + } + } + + /// `n` nested lists around bytes, which serde_json writes as one more array. + struct BytesUnder(u32); + + impl serde::Serialize for BytesUnder { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeSeq; + let Some(inner) = self.0.checked_sub(1) else { + return s.serialize_bytes(&[1]); + }; + let mut seq = s.serialize_seq(Some(1))?; + seq.serialize_element(&BytesUnder(inner))?; + seq.end() + } + } + + #[test] + fn bytes_count_as_one_level() { + for n in [126, 127] { + let text = serde_json::to_string(&BytesUnder(n)).unwrap(); + let reads_back = serde_json::from_str::(&text).is_ok(); + assert_eq!(check(&BytesUnder(n)).is_ok(), reads_back, "depth {n} agrees with serde_json"); + assert_eq!(check(&BytesUnder(n)).is_ok(), n == 126, "depth {n}"); + } + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a4bffbe0c..68772e00b 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -8,6 +8,7 @@ pub mod build; pub mod essential; +mod file_check; pub mod limits; pub mod props; pub mod render_queue; @@ -1519,13 +1520,20 @@ impl Project { && self.render_prefs == other.render_prefs } /// The project file's text: the project with the version that wrote it (`savedBy`), checked - /// to read back, so a save never writes a file that can't be opened (JSON has no NaN or - /// infinity: such a value would be written as `null`). + /// to read back, so a save never writes a file that can't be opened. Values JSON can't hold + /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every + /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { self.to_file_json_with_view(&SavedView::default()) } /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + if self.schema > SCHEMA_VERSION { + return Err(ProjectError::Invalid(format!( + "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", + self.schema + ))); + } #[derive(Serialize)] struct File<'a> { #[serde(rename = "savedBy")] @@ -1535,8 +1543,11 @@ impl Project { #[serde(flatten)] project: &'a Project, } - let text = serde_json::to_string_pretty(&File { saved_by: APP_VERSION, view, project: self }) - .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let file = File { saved_by: APP_VERSION, view, project: self }; + file_check::check(&file) + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; + let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; Ok(text) diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index 8cd52092f..3c40e8623 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -166,10 +166,33 @@ fn project_files_name_their_version_and_always_read_back() { p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::NAN); let e = p.to_file_json().unwrap_err().to_string(); assert!(e.contains("can't be saved"), "{e}"); + // Infinity too, in either profile: the check doesn't rely on reading the file back. + p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(f64::INFINITY); + let e = p.to_file_json().unwrap_err().to_string(); + assert!(e.contains("can't be saved"), "{e}"); assert!(crate::is_newer_version("0.2.0", "0.1.9") && crate::is_newer_version("1.0.0-rc.1", "0.9.9")); assert!(!crate::is_newer_version("0.1.1", "0.1.1") && !crate::is_newer_version("0.1.0", "0.1.1")); } +#[test] +fn project_files_refuse_newer_schemas() { + let p = Project { schema: crate::SCHEMA_VERSION + 1, ..Project::default() }; + let expected = format!( + "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({}))", + p.schema, + crate::SCHEMA_VERSION + ); + assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); +} + +#[test] +fn saved_files_reopen_in_pretty_and_compact_form() { + let (p, _cid) = project_with_layer(); + assert_eq!(Project::from_json(&p.to_file_json().unwrap()).unwrap(), p, "the pretty project file"); + assert_eq!(Project::from_json(&p.to_json_compact()).unwrap(), p, "compact JSON"); +} + #[test] fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); From 2b3732153b10f8e3e306808d8df4b80b522ae613 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:45:28 +0530 Subject: [PATCH 0272/1386] GPU: devices favour memory use, so Purge and Close give video memory back Both devices EffectCraft creates now ask for `MemoryHints::MemoryUsage`: - the window device (`apps/effectcraft/src/main.rs`, through a new `device_descriptor` helper); - the headless compositor device (`crates/gpu/src/context.rs`). The limits, labels, features and error paths are unchanged. The devices tests create stay as they were. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft/src/main.rs | 28 +++++++++++++++++++++++----- crates/gpu/src/context.rs | 11 +++++++---- crates/gpu/src/tests.rs | 10 ++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a1fb6e4a5..2bc3265dd 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -371,15 +371,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 { @@ -539,6 +546,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() { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..2fbf86bcd 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -462,6 +462,12 @@ pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Opt buffer } +/// The descriptor for a device the compositor owns: `MemoryUsage` so freed textures give their +/// allocator blocks back instead of a few long-lived objects pinning large ones. +pub(crate) fn device_descriptor(required_limits: wgpu::Limits) -> wgpu::DeviceDescriptor<'static> { + wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, memory_hints: wgpu::MemoryHints::MemoryUsage, ..Default::default() } +} + impl GpuContext { /// [`buffer_init`] on this context's device. pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { @@ -870,10 +876,7 @@ impl GpuContext { max_buffer_size: limits.max_buffer_size, ..wgpu::Limits::default() }; - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }) - .await - .map_err(|e| match by_name { + let (device, queue) = adapter.request_device(&device_descriptor(required_limits)).await.map_err(|e| match by_name { true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name), false => format!("no device: {e}"), })?; diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 24f8c9614..b466e5396 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1071,3 +1071,13 @@ fn upload_cache_drops_textures_of_freed_buffers() { let kept = g.ctx.upload_entries(); assert!(kept <= before + 1, "the upload cache kept {kept} textures of freed buffers (had {before})"); } + +/// The device the compositor owns asks for `MemoryUsage` so freed textures give +/// their allocator blocks back, while its label and limits stay as they were. +#[test] +fn owned_devices_favour_memory_usage() { + let d = crate::context::device_descriptor(wgpu::Limits::default()); + assert!(matches!(d.memory_hints, wgpu::MemoryHints::MemoryUsage)); + assert_eq!(d.label, Some("effectcraft gpu")); + assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d); +} From b48a77cf70b5e97cc1dba644636511638558902f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:46:39 +0530 Subject: [PATCH 0273/1386] Preview: a zoom or resolution change evicts the other sizes' frames first `crates/ui-egui/src/frames.rs`: - The cache remembers the scale of the frame on screen (the active viewer's urgent request). Prefetch and passive viewers don't change it, so a second viewer at another zoom can't evict the active viewer's frames. - RAM and GPU eviction take frames of other scales first, then fall back to the existing playhead-aware order. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/frames.rs | 101 ++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..6a354e456 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -152,6 +152,8 @@ struct Cache { gpu_budget: usize, /// The playing preview, if any: evictions spare the frames it shows next. playhead: Option, + /// Scale of the frame on screen (the newest urgent request): frames of other scales go first. + scale: Option, } impl Cache { @@ -173,8 +175,14 @@ impl Cache { /// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU /// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet) /// to keep the ones just shown, so a preview longer than the cache played into a gap that - /// prefetch kept refilling while it evicted the next frames (#595). + /// prefetch kept refilling while it evicted the next frames (#595). Frames of another scale + /// than the one on screen go first (a zoom or resolution change). fn victim(&self) -> Option { + if let Some(s) = self.scale + && let Some(i) = self.order.iter().position(|k| k.scale != s) + { + return Some(i); + } let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) }; let mut farthest: Option<(i64, usize)> = None; for (i, k) in self.order.iter().enumerate() { @@ -186,10 +194,13 @@ impl Cache { } farthest.map(|(_, i)| i) } - /// Video memory: drop the oldest GPU frames past their own budget. + /// Video memory: drop the oldest GPU frames past their own budget, frames of another scale + /// than the one on screen first (a zoom or resolution change). fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { - let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break }; + let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu); + let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k)); + let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break }; if let Some(old) = self.order.remove(i) { self.remove(&old); } @@ -462,7 +473,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -893,7 +904,19 @@ impl Frames { } fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { - if self.is_cached(&key) { + // The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at + // another zoom must not evict the active viewer's frames. + let cached = self + .cache + .lock() + .map(|mut c| { + if urgent { + c.scale = Some(key.scale); + } + c.map.contains_key(&key) + }) + .unwrap_or(false); + if cached { return; } let Ok(mut q) = self.queue.lock() else { return }; @@ -1795,21 +1818,17 @@ mod tests { assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0))); } - /// #595: prefetch renders the frames the preview reaches first, whatever order they were - /// asked for in (a frame evicted and asked for again waited behind every frame further - /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. - #[test] - fn prefetch_renders_the_frames_the_playhead_reaches_first() { - struct Idle; - impl RemoteFrames for Idle { - fn slots(&self) -> usize { - 0 - } - fn start(&self, _: RemoteJob, _: RemoteDone) {} + /// A frame source that queues jobs and never renders them: the tests check the queue. + struct Idle; + impl RemoteFrames for Idle { + fn slots(&self) -> usize { + 0 } - let mut f = Frames::default(); - f.set_remote(Some(Arc::new(Idle))); - let src = RenderSource { + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + + fn idle_source() -> RenderSource { + RenderSource { project: Arc::default(), footage: Arc::new(effectcraft_engine::render::NoFootage), expr: None, @@ -1817,7 +1836,17 @@ mod tests { gpu: None, gpu_display: false, disk: None, - }; + } + } + + /// #595: prefetch renders the frames the preview reaches first, whatever order they were + /// asked for in (a frame evicted and asked for again waited behind every frame further + /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. + #[test] + fn prefetch_renders_the_frames_the_playhead_reaches_first() { + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + let src = idle_source(); for frame in [40, 30, 20, 5, 12, 11] { f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default()); } @@ -1834,6 +1863,38 @@ mod tests { assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); } + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old + /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back + /// at went before the ones of the abandoned zoom. The other size's frames go first, then the + /// order is as before. + #[test] + fn a_size_change_evicts_frames_of_other_sizes_first() { + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + f.set_budget(4 * 16); + let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) }; + // 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest. + for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] { + insert(&f, at(frame, scale)); + } + // A passive viewer at 50% asks for a frame: that doesn't change which size is kept. + f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default()); + // Back at 100%: the viewer asks for frame 4. + f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default()); + f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default()); + insert(&f, at(4, 1000)); + assert!( + f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)), + "the first frame of the old size goes, not the cached ones of the current size" + ); + insert(&f, at(5, 1000)); + assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size"); + assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]); + // No other size left: least recently shown first again. + insert(&f, at(6, 1000)); + assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); From 506a5b327f07c43cf1e61307022c37a9c6711059 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:47:18 +0530 Subject: [PATCH 0274/1386] Timeline: the disk-cache bar checks only the visible frames - `Frames::disk_frames` takes a frame range instead of a frame count, still clamped to the existing 100,000-frame limit. - The Timeline passes the visible range (`disk_bar_range`), including frames only partly in view, and clips the blue bar to the work-area row. - Membership stays live: nothing is cached, so the bar updates as before when frames are written, evicted or promoted to RAM. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/frames.rs | 109 +++++++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 38 ++++++- crates/ui-egui/tests/ui_web_gpu_storage.rs | 2 +- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..7e4036a64 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -758,7 +758,12 @@ impl Frames { /// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar. /// `opts` are the viewer's render options (part of the disk key). Native queries return /// no blue frames until the content key is ready; hashing never blocks timeline painting. - pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec { + pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, range: std::ops::Range, opts: &RenderOpts) -> Vec { + let start = range.start.clamp(0, 100_000); + let end = range.end.clamp(0, 100_000); + if start >= end { + return vec![]; + } let remote = self.remote.as_ref().filter(|r| r.disk()); let contains = |k: u128| match (&src.disk, remote) { (Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k), @@ -781,10 +786,7 @@ impl Frames { let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); - (0..frames.clamp(0, 100_000)) - .filter(|f| !ram.contains(f)) - .filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))) - .collect() + (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() } #[cfg(not(target_arch = "wasm32"))] @@ -1375,6 +1377,54 @@ impl Queue { mod tests { use super::*; + struct ProbeDisk { + keys: Mutex>, + probes: std::sync::atomic::AtomicUsize, + } + + impl RemoteFrames for ProbeDisk { + fn slots(&self) -> usize { + 0 + } + + fn disk(&self) -> bool { + true + } + + fn disk_contains(&self, key: u128) -> bool { + self.probes.fetch_add(1, Ordering::Relaxed); + self.keys.lock().unwrap().contains(&key) + } + + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + + fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc, u128) { + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let opts = RenderOpts::default(); + let content = 1234; + let mut base = key(1, 0, opts_hash(&opts)); + base.revision = 7; + base.comp = comp.0; + let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() }); + disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts)))); + let mut frames = Frames::default(); + frames.set_remote(Some(disk.clone())); + frames.content_keys.lock().unwrap().insert((base.revision, base.comp), content); + let source = RenderSource { + project: session.project.clone(), + footage: session.footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + (frames, source, base, opts, disk, content) + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { @@ -1422,7 +1472,7 @@ mod tests { base.comp = cid.0; for revision in 1..50 { base.revision = revision; - assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty()); + assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty()); } assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked"); assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); @@ -1432,10 +1482,55 @@ mod tests { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } - assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready"); + assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready"); assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed"); } + #[test] + fn disk_indicator_range_tracks_live_membership() { + let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 3); + + disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts))); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]); + + insert(&frames, FrameKey { frame: 11, ..base }); + disk.probes.store(0, Ordering::Relaxed); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 2); + + disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts))); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]); + + let mut revised = base; + revised.revision += 1; + frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp), 5678); + assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty()); + + let changed = FrameKey { scale: base.scale + 1, ..base }; + assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty()); + let changed = FrameKey { view: base.view + 1, ..base }; + assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty()); + assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty()); + } + + #[test] + fn disk_indicator_range_is_bounded() { + let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]); + assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 2); + disk.probes.store(0, Ordering::Relaxed); + assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 1); + disk.probes.store(0, Ordering::Relaxed); + assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty()); + // Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges. + let reversed = std::ops::Range { start: 3, end: 2 }; + assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty()); + assert_eq!(disk.probes.load(Ordering::Relaxed), 0); + } + /// Frames larger than the texture limit are averaged down by a whole factor (#201: an /// 11000×2200 comp at Full resolution against an 8192 limit). #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..6ba329e58 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -515,6 +515,19 @@ impl TMap { } } +fn disk_bar_range(tm: &TMap, clip: Rect, fd: f64, frames: i64) -> std::ops::Range { + if clip.width() <= 0.0 || clip.height() <= 0.0 || !fd.is_finite() || fd <= 0.0 || !tm.pps.is_finite() || tm.pps <= 0.0 { + return 0..0; + } + let start = tm.t(clip.min.x) / fd; + let end = tm.t(clip.max.x) / fd; + if !start.is_finite() || !end.is_finite() { + return 0..0; + } + let limit = frames.clamp(0, 100_000) as f64; + (start.clamp(0.0, limit).floor() as i64)..(end.clamp(0.0, limit).ceil() as i64) +} + /// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when /// the project's Time Display Style is Frames, the frame number the current-time display shows /// (`00015`, counted from Start Numbering Frames At) (#710). @@ -1674,11 +1687,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.session.disk_cache.is_some() || app.frames.remote_disk() { let frames = comp.frame_rate.frame_at(comp.duration); let opts = app.frame_opts(cid, series.scale as f64 / 1000.0); - let disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts); + let clip = wa_row.intersect(p.clip_rect()); + let range = disk_bar_range(&tm, clip, fd, frames); + let disk = app.frames.disk_frames(&app.render_source(), &series, range, &opts); + let blue_painter = p.with_clip_rect(clip); let draw_blue = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); - p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue); + blue_painter.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue); }; let mut run: Option<(i64, i64)> = None; for f in disk { @@ -4720,4 +4736,22 @@ mod tests { let sel = bar_color(red, true); assert!(sel.r() > bar.r()); } + + #[test] + fn disk_bar_range_covers_partial_frames() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(2.0, 1.0)); + let tm = TMap { x0: 0.0, start: 10.25, pps: 1.0 }; + assert_eq!(disk_bar_range(&tm, clip, 1.0, 100), 10..13); + + let exact = TMap { start: 10.0, ..tm }; + assert_eq!(disk_bar_range(&exact, clip, 1.0, 100), 10..12); + let off_comp = TMap { start: 200.0, ..tm }; + assert_eq!(disk_bar_range(&off_comp, clip, 1.0, 100), 100..100); + assert_eq!(disk_bar_range(&tm, clip, f64::NAN, 100), 0..0); + assert_eq!(disk_bar_range(&TMap { pps: 0.0, ..tm }, clip, 1.0, 100), 0..0); + assert_eq!(disk_bar_range(&TMap { start: f64::NAN, ..tm }, clip, 1.0, 100), 0..0); + assert_eq!(disk_bar_range(&tm, Rect::from_min_max(pos2(2.0, 0.0), pos2(2.0, 1.0)), 1.0, 100), 0..0); + let above_limit = TMap { start: 100_001.0, ..tm }; + assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); + } } diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index d706a382b..9977858c9 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -172,7 +172,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { assert!(frames2.remote_disk()); let until = std::time::Instant::now() + std::time::Duration::from_secs(5); let bar = loop { - let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + let bar = frames2.disk_frames(&source(&s, None), &key(fx), 0..30, &opts); if !bar.is_empty() { break bar; } From 17d57a67058b1fe52fd600d76564bcfb16bbb4bd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 12:41:50 -0400 Subject: [PATCH 0275/1386] Light Settings: list Environment and keep an existing light's type (#1588) The dialog offered four hard-coded light types and reopened an Environment light as Spot, so OK turned it into one. It now lists every light type the engine has, keeps the light's own type, and shows Falloff and Shadows only for lights that have them. --- crates/ui-egui/src/panels/dialogs_3d.rs | 36 +++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..01fd5b9e8 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -29,7 +29,7 @@ pub struct CameraDraft { pub struct LightDraft { pub layer: Option, pub name: String, - pub kind: usize, + pub kind: LightKind, pub color: [f32; 3], pub intensity: f64, pub cone_angle: f64, @@ -42,9 +42,14 @@ pub struct LightDraft { pub shadow_diffusion: f64, } -const KINDS: [LightKind; 4] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient]; const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; +/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come +/// from no one place, so they have neither. +fn falls_off(kind: LightKind) -> bool { + !matches!(kind, LightKind::Ambient | LightKind::Environment) +} + fn comp_w(app: &EffectcraftApp) -> f64 { app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) } @@ -105,7 +110,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St LightDraft { layer: Some(l.id.0), name: l.name.clone(), - kind: KINDS.iter().position(|k| *k == kind).unwrap_or(1), + kind, color: [c[0], c[1], c[2]], intensity: f("intensity", 100.0), cone_angle: f("coneAngle", 90.0), @@ -123,7 +128,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St LightDraft { layer: None, name: format!("Light {n}"), - kind: 1, + kind: LightKind::Spot, color: [1.0, 1.0, 1.0], intensity: 100.0, cone_angle: 90.0, @@ -263,11 +268,13 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); ui.end_row(); ui.label(tr("Light Type")); - egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| { - for (i, k) in KINDS.iter().enumerate() { - ui.selectable_value(&mut d.kind, i, k.label()); + let combo = egui::ComboBox::from_id_salt("light-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| { + for (i, k) in LightKind::ALL.into_iter().enumerate() { + let item = ui.selectable_value(&mut d.kind, k, tr(k.label())); + app.auto.add(&format!("dialog.light.typeItem.{i}"), item.rect, k.label()); } }); + app.auto.add_response("dialog.light.type", &combo.response, "Light Type"); ui.end_row(); ui.label(tr("Color")); crate::widgets::srgb_color_button(ui, &mut d.color); @@ -275,8 +282,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.label(tr("Intensity")); ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); ui.end_row(); - let kind = KINDS[d.kind.min(3)]; - if kind == LightKind::Spot { + if d.kind == LightKind::Spot { ui.label(tr("Cone Angle")); ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"))); ui.end_row(); @@ -284,7 +290,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"))); ui.end_row(); } - if kind != LightKind::Ambient { + if falls_off(d.kind) { ui.label(tr("Falloff")); egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| { for (i, f) in FALLOFFS.iter().enumerate() { @@ -312,15 +318,17 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } }); ui.add_space(14.0); - ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); - ui.add_space(8.0); + if falls_off(d.kind) { + ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); + ui.add_space(8.0); + } rects = buttons(ui, t, &mut ok, &mut close); }); app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); app.dialog_state.light = d.clone(); if ok || super::dialogs::ui_enter(ctx) { - let kind = KINDS[d.kind.min(3)]; + let kind = d.kind; let mut p = json!({ "name": d.name, "kind": kind.label(), @@ -331,7 +339,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { p["coneAngle"] = json!(d.cone_angle); p["coneFeather"] = json!(d.cone_feather); } - if kind != LightKind::Ambient { + if falls_off(kind) { p["falloff"] = json!(d.falloff); p["radius"] = json!(d.radius); p["falloffDistance"] = json!(d.falloff_distance); From b9929c2d7108ea30b062ba2d34460efd3cfc1120 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 12:41:50 -0400 Subject: [PATCH 0276/1386] Light Settings: test Environment lights and the other types through the dialog (#1588) --- crates/ui-egui/tests/ui_light_settings.rs | 172 ++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 crates/ui-egui/tests/ui_light_settings.rs diff --git a/crates/ui-egui/tests/ui_light_settings.rs b/crates/ui-egui/tests/ui_light_settings.rs new file mode 100644 index 000000000..a4284423a --- /dev/null +++ b/crates/ui-egui/tests/ui_light_settings.rs @@ -0,0 +1,172 @@ +//! Light Settings dialog (Layer ▸ New ▸ Light, Layer Settings on a light): every light type the +//! engine has, Environment included, and an existing light's type kept when it's reopened (#1588). + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerId, LayerSource, LightKind}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Lights", "width": 640, "height": 360, "duration": 2})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.step(); + h +} + +/// An automation element's rect in the last frame drawn (a dialog settles its place in its +/// first frames, so the frame before can be stale). +fn element(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option { + let auto = &h.state().auto; + auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).cloned() +} + +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = element(h, id).unwrap_or_else(|| panic!("{id} is registered")).rect; + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(2); +} + +/// Picks the dialog's Light Type `kind` from its menu. +fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: LightKind) { + click_id(h, "dialog.light.type"); + let i = LightKind::ALL.iter().position(|k| *k == kind).unwrap(); + click_id(h, &format!("dialog.light.typeItem.{i}")); +} + +fn ok(h: &mut Harness<'_, EffectcraftApp>) { + click_id(h, "dialog.light.ok"); + assert_eq!(h.state().dialog, None); +} + +fn layer(h: &Harness<'_, EffectcraftApp>, id: u64) -> Layer { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn kind(h: &Harness<'_, EffectcraftApp>, id: u64) -> LightKind { + match layer(h, id).source { + LayerSource::Light { kind } => kind, + other => panic!("not a light: {other:?}"), + } +} + +fn last_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).max().unwrap() +} + +/// Opens Light Settings for an existing light, as Layer Settings does. +fn reopen(h: &mut Harness<'_, EffectcraftApp>, id: u64) { + invoke(h, "layer.settings", json!({"layer": id})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); +} + +/// An Environment light lit from a solid, its image turned 30°. +fn environment_light(h: &mut Harness<'_, EffectcraftApp>) -> (u64, u64) { + let s = &mut h.state_mut().session; + let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap(); + let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env", "intensity": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap(); + s.execute("prop.set", json!({"layer": env, "path": "lightOptions/rotation", "value": 30})).unwrap(); + h.run_steps(2); + (env, sky) +} + +#[test] +fn the_light_type_menu_lists_every_light_type() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + click_id(&mut h, "dialog.light.type"); + for (i, k) in LightKind::ALL.iter().enumerate() { + let item = element(&h, &format!("dialog.light.typeItem.{i}")).map(|e| e.label); + assert_eq!(item.as_deref(), Some(k.label())); + } +} + +#[test] +fn new_light_creates_an_environment_light() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + let l = layer(&h, id); + assert!(l.props.prop("lightOptions/source").is_some() && l.props.prop("lightOptions/falloff").is_none()); + // One undoable step, redone as Environment. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_none()); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(kind(&h, id), LightKind::Environment); +} + +#[test] +fn reopening_an_environment_light_and_pressing_ok_keeps_it() { + let mut h = harness(); + let (env, sky) = environment_light(&mut h); + let before = layer(&h, env); + reopen(&mut h, env); + ok(&mut h); + let after = layer(&h, env); + assert_eq!(kind(&h, env), LightKind::Environment, "not turned into a Spot light"); + assert_eq!(after.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky)); + for path in ["lightOptions/rotation", "lightOptions/intensity"] { + assert_eq!(after.props.prop(path).unwrap().value, before.props.prop(path).unwrap().value, "{path}"); + } +} + +#[test] +fn cancelling_light_settings_changes_nothing() { + let mut h = harness(); + let (env, _) = environment_light(&mut h); + let before = serde_json::to_value(layer(&h, env)).unwrap(); + let steps = h.state().session.history.undo.len(); + reopen(&mut h, env); + choose(&mut h, LightKind::Spot); + click_id(&mut h, "dialog.light.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(serde_json::to_value(layer(&h, env)).unwrap(), before); + assert_eq!(h.state().session.history.undo.len(), steps); +} + +#[test] +fn changing_a_light_to_environment_undoes_and_redoes() { + let mut h = harness(); + let id = h.state_mut().session.execute("layer.newLight", json!({"kind": "Spot", "coneAngle": 45})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(2); + reopen(&mut h, id); + choose(&mut h, LightKind::Environment); + ok(&mut h); + assert_eq!(kind(&h, id), LightKind::Environment); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(kind(&h, id), LightKind::Spot); + assert_eq!(layer(&h, id).props.prop("lightOptions/coneAngle").unwrap().value.as_f64(), 45.0); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(kind(&h, id), LightKind::Environment); +} + +#[test] +fn the_other_light_types_are_created_and_kept() { + let mut h = harness(); + for k in [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient] { + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, k); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), k); + reopen(&mut h, id); + ok(&mut h); + assert_eq!(kind(&h, id), k, "{} kept on reopening", k.label()); + } +} From 83cd74c7305d5b2ce14630828579e556ff9b9ec0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 13:14:46 -0400 Subject: [PATCH 0277/1386] Light Settings: test which options each light type shows, and undoing Environment to Spot (#1588) --- crates/ui-egui/tests/ui_light_settings.rs | 31 ++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_light_settings.rs b/crates/ui-egui/tests/ui_light_settings.rs index a4284423a..fe1392be1 100644 --- a/crates/ui-egui/tests/ui_light_settings.rs +++ b/crates/ui-egui/tests/ui_light_settings.rs @@ -4,7 +4,7 @@ use effectcraft_engine::Session; use effectcraft_engine::project::{Layer, LayerId, LayerSource, LightKind}; use effectcraft_ui_egui::{Dialog, EffectcraftApp}; -use egui_kittest::Harness; +use egui_kittest::{Harness, kittest::Queryable as _}; use serde_json::{Value, json}; fn harness() -> Harness<'static, EffectcraftApp> { @@ -170,3 +170,32 @@ fn the_other_light_types_are_created_and_kept() { assert_eq!(kind(&h, id), k, "{} kept on reopening", k.label()); } } + +#[test] +fn falloff_and_shadow_options_show_only_for_lights_that_have_them() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + for k in LightKind::ALL { + choose(&mut h, k); + let shown = !matches!(k, LightKind::Ambient | LightKind::Environment); + for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label()); + } + } +} + +#[test] +fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() { + let mut h = harness(); + let (env, sky) = environment_light(&mut h); + reopen(&mut h, env); + choose(&mut h, LightKind::Spot); + ok(&mut h); + assert_eq!(kind(&h, env), LightKind::Spot); + assert!(layer(&h, env).props.prop("lightOptions/source").is_none()); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + let l = layer(&h, env); + assert_eq!(kind(&h, env), LightKind::Environment); + assert_eq!(l.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky)); + assert_eq!(l.props.prop("lightOptions/rotation").unwrap().value.as_f64(), 30.0); +} From f14a57fb3e49162e0710f1b9d1fc46cc6c2685c3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 13:31:58 -0400 Subject: [PATCH 0278/1386] 3D: Layer > New > 3D Primitive... dialog for a primitive's type, name and dimensions (#1734) OK runs layer.newPrimitive, which switches a Classic 3D comp to Advanced 3D in the same undo step. The dimension fields are the Geometry Options the engine builds for the chosen type. --- crates/engine/src/menus.rs | 1 + crates/ui-egui/src/i18n.rs | 10 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 2 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/primitive_dialog.rs | 109 ++++++++++++++++++ 7 files changed, 129 insertions(+) create mode 100644 crates/ui-egui/src/panels/primitive_dialog.rs diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c5..aa2e4ffe1 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -733,6 +733,7 @@ Layer Shape Layer | layer.newShape Adjustment Layer | layer.newAdjustment Content-Aware Fill Layer... | layer.newContentAwareFill + 3D Primitive... | layer.newPrimitive Cube | layer.new3dPrimitive {"kind":"cube"} Sphere | layer.new3dPrimitive {"kind":"sphere"} Plane | layer.new3dPrimitive {"kind":"plane"} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b51..5bad9cdd5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -505,6 +505,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "形状图层"), ("layer.newAdjustment", "Adjustment Layer", "调整图层"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "内容识别填充图层..."), + ("layer.newPrimitive", "3D Primitive...", "3D 基本体..."), ("layer.new3dPrimitive", "Cube", "立方体"), ("layer.new3dPrimitive", "Sphere", "球体"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -1139,6 +1140,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "形狀圖層"), ("layer.newAdjustment", "Adjustment Layer", "調整圖層"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "內容感知填滿圖層..."), + ("layer.newPrimitive", "3D Primitive...", "3D 基本體..."), ("layer.new3dPrimitive", "Cube", "立方體"), ("layer.new3dPrimitive", "Sphere", "球體"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -1770,6 +1772,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "シェイプレイヤー"), ("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."), + ("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."), ("layer.new3dPrimitive", "Cube", "立方体"), ("layer.new3dPrimitive", "Sphere", "球体"), ("layer.new3dPrimitive", "Plane", "平面"), @@ -2404,6 +2407,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Шар фігури"), ("layer.newAdjustment", "Adjustment Layer", "Коригувальний шар"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Шар заливки з урахуванням вмісту..."), + ("layer.newPrimitive", "3D Primitive...", "3D-примітив..."), ("layer.new3dPrimitive", "Cube", "Куб"), ("layer.new3dPrimitive", "Sphere", "Сфера"), ("layer.new3dPrimitive", "Plane", "Площина"), @@ -3039,6 +3043,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Capa de forma"), ("layer.newAdjustment", "Adjustment Layer", "Capa de ajuste"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Capa de relleno según el contenido..."), + ("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."), ("layer.new3dPrimitive", "Cube", "Cubo"), ("layer.new3dPrimitive", "Sphere", "Esfera"), ("layer.new3dPrimitive", "Plane", "Plano"), @@ -3672,6 +3677,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Camada de forma"), ("layer.newAdjustment", "Adjustment Layer", "Camada de ajuste"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Camada de preenchimento sensível ao conteúdo..."), + ("layer.newPrimitive", "3D Primitive...", "Primitiva 3D..."), ("layer.new3dPrimitive", "Cube", "Cubo"), ("layer.new3dPrimitive", "Sphere", "Esfera"), ("layer.new3dPrimitive", "Plane", "Plano"), @@ -4312,6 +4318,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Слой-фигура"), ("layer.newAdjustment", "Adjustment Layer", "Корректирующий слой"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Слой заливки с учетом содержимого..."), + ("layer.newPrimitive", "3D Primitive...", "3D-примитив..."), ("layer.new3dPrimitive", "Cube", "Куб"), ("layer.new3dPrimitive", "Sphere", "Сфера"), ("layer.new3dPrimitive", "Plane", "Плоскость"), @@ -4945,6 +4952,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Calque de forme"), ("layer.newAdjustment", "Adjustment Layer", "Calque de réglage"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Calque de remplissage d’après le contenu..."), + ("layer.newPrimitive", "3D Primitive...", "Primitive 3D..."), ("layer.new3dPrimitive", "Cube", "Cube"), ("layer.new3dPrimitive", "Sphere", "Sphère"), ("layer.new3dPrimitive", "Plane", "Plan"), @@ -5578,6 +5586,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Formebene"), ("layer.newAdjustment", "Adjustment Layer", "Einstellungsebene"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Ebene für inhaltsbasierte Füllung..."), + ("layer.newPrimitive", "3D Primitive...", "3D-Grundkörper..."), ("layer.new3dPrimitive", "Cube", "Würfel"), ("layer.new3dPrimitive", "Sphere", "Kugel"), ("layer.new3dPrimitive", "Plane", "Fläche"), @@ -6212,6 +6221,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.newShape", "Shape Layer", "Vrstva tvaru"), ("layer.newAdjustment", "Adjustment Layer", "Vrstva úprav"), ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "Vrstva výplně podle obsahu..."), + ("layer.newPrimitive", "3D Primitive...", "3D primitivum..."), ("layer.new3dPrimitive", "Cube", "Krychle"), ("layer.new3dPrimitive", "Sphere", "Koule"), ("layer.new3dPrimitive", "Plane", "Rovina"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..783f07798 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,8 @@ pub enum Dialog { ViewOptions, CameraSettings, LightSettings, + /// Layer ▸ New ▸ 3D Primitive… (`panels::primitive_dialog`). + NewPrimitive, KeyVelocity, KeyInterpolation, TimeStretch, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..f04778bbe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -399,6 +399,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::dialogs_3d::route(app, id, ¶ms)? { return Ok(Value::Null); } + // New 3D Primitive without parameters opens its dialog. + if crate::panels::primitive_dialog::route(app, id, ¶ms)? { + return Ok(Value::Null); + } // Layer Settings on a solid, adjustment layer or null opens its settings dialog. if crate::panels::dialogs::route_layer_settings(app, id, ¶ms)? { return Ok(Value::Null); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f4413d147 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -47,6 +47,7 @@ pub struct DialogState { pub info: (String, String), pub camera: super::dialogs_3d::CameraDraft, pub light: super::dialogs_3d::LightDraft, + pub primitive: super::primitive_dialog::PrimitiveDraft, pub velocity: super::key_dialogs::VelocityDraft, pub interp: super::key_dialogs::InterpDraft, pub stretch: super::key_dialogs::StretchDraft, @@ -248,6 +249,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t), Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t), Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t), + Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t), Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t), Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3aa9a19c5..f65b39706 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -35,6 +35,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub mod primitive_dialog; pub mod project; pub mod properties; pub mod puppet_tool; diff --git a/crates/ui-egui/src/panels/primitive_dialog.rs b/crates/ui-egui/src/panels/primitive_dialog.rs new file mode 100644 index 000000000..7626984a3 --- /dev/null +++ b/crates/ui-egui/src/panels/primitive_dialog.rs @@ -0,0 +1,109 @@ +//! New 3D Primitive dialog (Layer ▸ New ▸ 3D Primitive…): the primitive's type, name and +//! dimensions. OK runs `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D; +//! the layer's Geometry Options edit it afterwards. + +use std::collections::BTreeMap; + +use crate::i18n::tr; +use effectcraft_engine::project::build::{Ids, primitive_geometry_options}; +use effectcraft_engine::project::{ParamUi, PrimitiveKind, Property}; +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Debug, Default)] +pub struct PrimitiveDraft { + pub kind: PrimitiveKind, + pub name: String, + /// Whether the name was typed; until then it follows the type. + pub named: bool, + /// Dimensions by Geometry Options match id, kept when the type changes (a sphere's radius + /// carries over to a cone). + pub dims: BTreeMap, +} + +/// The dimensions a primitive type has: its Geometry Options as the engine builds them. +fn dimensions(kind: PrimitiveKind) -> Vec { + let mut next = 0; + primitive_geometry_options(&mut Ids(&mut next), kind).props().cloned().collect() +} + +/// Open the dialog for a new primitive in the active comp. +pub fn open(app: &mut EffectcraftApp) -> Result<(), String> { + app.session.active_comp().ok_or("no composition is open")?; + let kind = PrimitiveKind::Cube; + app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new() }; + app.dialog = Some(Dialog::NewPrimitive); + Ok(()) +} + +pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.primitive.clone(); + let (mut ok, mut close) = (false, false); + super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| { + egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { + ui.label(tr("Type")); + let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| { + for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() { + let item = ui.selectable_value(&mut d.kind, k, tr(k.label())); + app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label()); + } + }); + app.auto.add_response("dialog.primitive.type", &combo.response, "Type"); + if !d.named { + d.name = d.kind.label().to_string(); + } + ui.end_row(); + ui.label(tr("Name")); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); + d.named |= r.changed(); + app.auto.add_response("dialog.primitive.name", &r, "Name"); + ui.end_row(); + for p in dimensions(d.kind) { + let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue }; + let v = d.dims.entry(p.match_id.clone()).or_insert(p.value.as_f64()); + ui.label(tr(&p.name)); + // Counts (segments, rings) are whole numbers; sizes are pixels. + let suffix = if decimals == 0 { "" } else { " px" }; + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); + app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name); + ui.end_row(); + } + }); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); + app.auto.add("dialog.primitive.ok", b.rect, "OK"); + ok |= b.clicked(); + let c = ui.button(tr("Cancel")); + app.auto.add("dialog.primitive.cancel", c.rect, "Cancel"); + close |= c.clicked(); + }); + }); + app.dialog_state.primitive = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut p = json!({"kind": d.kind.label().to_ascii_lowercase(), "name": d.name}); + for dim in dimensions(d.kind) { + if let Some(v) = d.dims.get(&dim.match_id) { + p[dim.match_id.as_str()] = json!(v); + } + } + if let Err(e) = app.session.execute("layer.newPrimitive", p) { + app.ui.status = e.to_string(); + } + close = true; + } + if close { + app.dialog = None; + } +} + +/// Menu routing: New 3D Primitive without parameters opens the dialog. Returns true when it did. +pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { + if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) { + return Ok(false); + } + open(app).map(|_| true) +} From cb561a6a3dec2f2da77cc11b31362786727d2f5d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 13:31:59 -0400 Subject: [PATCH 0279/1386] 3D: test the New 3D Primitive dialog from a blank comp (#1734) --- crates/ui-egui/tests/ui_new_primitive.rs | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 crates/ui-egui/tests/ui_new_primitive.rs diff --git a/crates/ui-egui/tests/ui_new_primitive.rs b/crates/ui-egui/tests/ui_new_primitive.rs new file mode 100644 index 000000000..2093e69d5 --- /dev/null +++ b/crates/ui-egui/tests/ui_new_primitive.rs @@ -0,0 +1,169 @@ +//! Layer ▸ New ▸ 3D Primitive… (#1734): the dialog picks a type, name and dimensions and runs +//! `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D in the same step. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerSource, PrimitiveKind, Renderer}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.step(); + h +} + +/// An automation element's rect in the last frame drawn (a dialog settles its place in its +/// first frames). +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<[f32; 4]> { + let auto = &h.state().auto; + auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).map(|e| e.rect) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = rect(h, id).unwrap_or_else(|| panic!("{id} is registered")); + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +/// Types `text` over a field's contents and leaves it with Tab. +fn type_into(h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str) { + click(h, id); + assert!(h.ctx.egui_wants_keyboard_input(), "{id} has focus"); + key(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + key(h, Key::Tab, Modifiers::NONE); +} + +/// Layer ▸ New ▸ 3D Primitive…, as the menu runs it. +fn open(h: &mut Harness<'_, EffectcraftApp>) { + let (path, entry) = + effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.label == "3D Primitive...").expect("Layer ▸ New lists 3D Primitive..."); + assert_eq!(path, ["Layer", "New"]); + assert_eq!(entry.command, "layer.newPrimitive"); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, &entry.command, entry.params_or_empty()).unwrap(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::NewPrimitive)); +} + +fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: PrimitiveKind) { + click(h, "dialog.primitive.type"); + let i = PrimitiveKind::ALL.iter().position(|k| *k == kind).unwrap(); + click(h, &format!("dialog.primitive.typeItem.{i}")); +} + +fn layers(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().unwrap().layers.clone() +} + +fn renderer(h: &Harness<'_, EffectcraftApp>) -> Renderer { + h.state().session.active_comp().unwrap().renderer +} + +fn geometry(l: &Layer, id: &str) -> f64 { + l.props.prop(&format!("geometryOptions/{id}")).unwrap_or_else(|| panic!("{id}")).value.as_f64() +} + +/// A torus named "Ring" with a 250 px radius, the rest left at their defaults. +fn create_ring(h: &mut Harness<'_, EffectcraftApp>) { + open(h); + choose(h, PrimitiveKind::Torus); + type_into(h, "dialog.primitive.name", "Ring"); + type_into(h, "dialog.primitive.radius", "250"); + click(h, "dialog.primitive.ok"); + assert_eq!(h.state().dialog, None); +} + +fn assert_ring(l: &Layer) { + assert_eq!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Torus }); + assert_eq!(l.name, "Ring"); + assert_eq!(geometry(l, "radius"), 250.0); + assert_eq!(geometry(l, "tubeRadius"), 50.0); +} + +#[test] +fn creates_a_primitive_in_a_classic_3d_comp_in_one_undoable_step() { + let mut h = harness(); + assert_eq!(renderer(&h), Renderer::Classic3D); + let steps = h.state().session.history.undo.len(); + create_ring(&mut h); + let ls = layers(&h); + assert_eq!(ls.len(), 1); + assert_ring(&ls[0]); + assert_eq!(renderer(&h), Renderer::Advanced3D, "the comp switches to Advanced 3D"); + assert_eq!(h.state().session.history.undo.len(), steps + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(layers(&h).is_empty()); + assert_eq!(renderer(&h), Renderer::Classic3D, "undo restores the renderer too"); + h.state_mut().session.execute("edit.redo", json!({})).unwrap(); + assert_ring(&layers(&h)[0]); + assert_eq!(renderer(&h), Renderer::Advanced3D); +} + +#[test] +fn cancel_creates_nothing() { + let mut h = harness(); + let steps = h.state().session.history.undo.len(); + open(&mut h); + choose(&mut h, PrimitiveKind::Sphere); + type_into(&mut h, "dialog.primitive.radius", "80"); + click(&mut h, "dialog.primitive.cancel"); + assert_eq!(h.state().dialog, None); + assert!(layers(&h).is_empty()); + assert_eq!(renderer(&h), Renderer::Classic3D); + assert_eq!(h.state().session.history.undo.len(), steps); +} + +#[test] +fn each_type_offers_the_dimensions_its_layer_has() { + let mut h = harness(); + for kind in PrimitiveKind::ALL { + open(&mut h); + choose(&mut h, kind); + click(&mut h, "dialog.primitive.ok"); + let l = layers(&h).into_iter().find(|l| l.source == LayerSource::Primitive { kind }).unwrap(); + assert_eq!(l.name, kind.label(), "the name follows the type until typed"); + let group = l.props.group("geometryOptions").unwrap(); + let ids: Vec<&str> = group.props().map(|p| p.match_id.as_str()).collect(); + open(&mut h); + choose(&mut h, kind); + let controls = ["type", "name", "ok", "cancel"]; + let shown: Vec<&str> = (h.state().auto.elements.iter()) + .filter_map(|e| e.id.strip_prefix("dialog.primitive.")) + .filter(|id| !controls.contains(id) && !id.starts_with("typeItem.")) + .collect(); + assert_eq!(shown, ids, "{} dimensions", kind.label()); + click(&mut h, "dialog.primitive.cancel"); + } +} + +#[test] +fn a_created_primitive_survives_save_and_reopen() { + let mut h = harness(); + create_ring(&mut h); + let path = std::env::temp_dir().join(format!("ec-ui-new-primitive-{}.ecproj", std::process::id())); + h.state_mut().session.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let _ = std::fs::remove_file(&path); + let (_, comp) = o.project.comps().next().unwrap(); + assert_eq!(comp.renderer, Renderer::Advanced3D); + assert_ring(&comp.layers[0]); +} From ec2d51a17a4cacc6b2907c875879f85ba895eed3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 13:55:18 -0400 Subject: [PATCH 0280/1386] ci: add SonarCloud scan workflow and config --- sonar-project.properties | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..66faf29c3 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,4 @@ +sonar.projectKey=storytold_craft-launcher +sonar.organization=storytold +sonar.sources=. +sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From 26b40199b72121aa7f4e55a63718e1b4dd48a859 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 14:04:43 -0400 Subject: [PATCH 0281/1386] Release 0.14.0 FreeBSD aarch64: build with 16 codegen units and no LTO in the emulated release VM, which ran past the job's 5-hour limit with the single-unit thin-LTO profile. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 ++++++++++++++++++------------------ Cargo.toml | 2 +- packaging/freebsd/package.sh | 8 ++++ 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee9c99cf2..2cd7ed3a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1200,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.13.0" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.13.0" +version = "0.14.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.13.0" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1359,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1410,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1481,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1541,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.13.0" +version = "0.14.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.13.0" +version = "0.14.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.13.0" +version = "0.14.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1605,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.13.0" +version = "0.14.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1681,11 +1681,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.13.0" +version = "0.14.0" [[package]] name = "effectcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 8c6f978ef..eecbbc7e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 236c464a6..7418dd85a 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -46,6 +46,14 @@ echo "==> EffectCraft $VERSION for FreeBSD $ARCH" # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory. export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" +# The aarch64 release VM is emulated (no rustup toolchain for this tier-3 target, so it builds +# natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a +# single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for +# parallel codegen there. x86_64 keeps the workspace's release profile. +if [ "$ARCH" = aarch64 ]; then + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}" + export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}" +fi if [ "$SKIP_BUILD" = 0 ]; then (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli) fi From c21210a63a578407a0dca9b7c84d12c9ab02d8b0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 14:51:41 -0400 Subject: [PATCH 0282/1386] 3D: close the New 3D Primitive dialog when its project or comp changes; untouched dimensions take the type's defaults (#1734) The draft records the project epoch and comp it was opened for. A script or the control channel opening a project or activating another comp while it is open closed nothing before, and OK added the primitive (and switched to Advanced 3D) in whatever comp was active then, even in a new project reusing the comp id. --- crates/ui-egui/src/panels/primitive_dialog.rs | 30 +++++++--- crates/ui-egui/tests/ui_new_primitive.rs | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/primitive_dialog.rs b/crates/ui-egui/src/panels/primitive_dialog.rs index 7626984a3..a3483a976 100644 --- a/crates/ui-egui/src/panels/primitive_dialog.rs +++ b/crates/ui-egui/src/panels/primitive_dialog.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use crate::i18n::tr; use effectcraft_engine::project::build::{Ids, primitive_geometry_options}; -use effectcraft_engine::project::{ParamUi, PrimitiveKind, Property}; +use effectcraft_engine::project::{ItemId, ParamUi, PrimitiveKind, Property}; use egui::{Color32, vec2}; use serde_json::{Value, json}; @@ -19,9 +19,15 @@ pub struct PrimitiveDraft { pub name: String, /// Whether the name was typed; until then it follows the type. pub named: bool, - /// Dimensions by Geometry Options match id, kept when the type changes (a sphere's radius - /// carries over to a cone). + /// Dimensions typed, by Geometry Options match id, kept when the type changes (a sphere's + /// radius carries over to a cone); the rest are the type's defaults. pub dims: BTreeMap, + /// The project (its epoch) and comp the dialog was opened for. + pub target: Option<(u64, ItemId)>, +} + +fn target(app: &EffectcraftApp) -> Option<(u64, ItemId)> { + Some((app.session.script_ui.project_epoch, app.session.active_comp_id()?)) } /// The dimensions a primitive type has: its Geometry Options as the engine builds them. @@ -32,15 +38,22 @@ fn dimensions(kind: PrimitiveKind) -> Vec { /// Open the dialog for a new primitive in the active comp. pub fn open(app: &mut EffectcraftApp) -> Result<(), String> { - app.session.active_comp().ok_or("no composition is open")?; + let target = Some(target(app).ok_or("no composition is open")?); let kind = PrimitiveKind::Cube; - app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new() }; + app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new(), target }; app.dialog = Some(Dialog::NewPrimitive); Ok(()) } pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.primitive.clone(); + // A project opened or another comp made active since (by a script or the control channel): + // the primitive would land somewhere the dialog was not opened for. + if target(app) != d.target { + app.ui.status = "New 3D Primitive closed: the composition changed".into(); + app.dialog = None; + return; + } let (mut ok, mut close) = (false, false); super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| { egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { @@ -63,12 +76,15 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); for p in dimensions(d.kind) { let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue }; - let v = d.dims.entry(p.match_id.clone()).or_insert(p.value.as_f64()); + let mut v = d.dims.get(&p.match_id).copied().unwrap_or(p.value.as_f64()); ui.label(tr(&p.name)); // Counts (segments, rings) are whole numbers; sizes are pixels. let suffix = if decimals == 0 { "" } else { " px" }; - let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut v).range(min..=max).max_decimals(decimals as usize).suffix(suffix))); app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name); + if r.changed() { + d.dims.insert(p.match_id, v); + } ui.end_row(); } }); diff --git a/crates/ui-egui/tests/ui_new_primitive.rs b/crates/ui-egui/tests/ui_new_primitive.rs index 2093e69d5..fbb3a34e5 100644 --- a/crates/ui-egui/tests/ui_new_primitive.rs +++ b/crates/ui-egui/tests/ui_new_primitive.rs @@ -167,3 +167,61 @@ fn a_created_primitive_survives_save_and_reopen() { assert_eq!(comp.renderer, Renderer::Advanced3D); assert_ring(&comp.layers[0]); } + +#[test] +fn untouched_dimensions_take_each_types_defaults_and_typed_ones_carry_over() { + let mut h = harness(); + open(&mut h); + choose(&mut h, PrimitiveKind::Plane); + click(&mut h, "dialog.primitive.ok"); + let plane = layers(&h).remove(0); + assert_eq!(geometry(&plane, "width"), 400.0, "a plane's default, not the cube's 300"); + open(&mut h); + type_into(&mut h, "dialog.primitive.width", "120"); + choose(&mut h, PrimitiveKind::Plane); + click(&mut h, "dialog.primitive.ok"); + let plane = layers(&h).into_iter().find(|l| l.id != plane.id).unwrap(); + assert_eq!(geometry(&plane, "width"), 120.0, "the typed width carries over"); + assert_eq!(geometry(&plane, "height"), 400.0); +} + +/// A dialog left open while something else changes the editing context closes without creating +/// anything rather than adding the primitive where it was not opened. +fn closes_when(change: impl FnOnce(&mut Session)) { + let mut h = harness(); + let a = h.state().session.active_comp_id().unwrap(); + open(&mut h); + choose(&mut h, PrimitiveKind::Torus); + change(&mut h.state_mut().session); + let b = h.state().session.active_comp_id().unwrap(); + let steps = h.state().session.history.undo.len(); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "the dialog closes"); + assert!(rect(&h, "dialog.primitive.ok").is_none()); + let s = &h.state().session; + for c in [a, b] { + if let Some(c) = s.project.comp(c) { + assert!(c.layers.is_empty(), "nothing is created"); + assert_eq!(c.renderer, Renderer::Classic3D); + } + } + assert_eq!(s.history.undo.len(), steps); + assert!(h.state().ui.status.contains("changed"), "{}", h.state().ui.status); +} + +#[test] +fn another_comp_made_active_closes_the_dialog() { + closes_when(|s| { + s.execute("comp.new", json!({"name": "Other", "width": 640, "height": 360, "duration": 2})).unwrap(); + }); +} + +#[test] +fn a_replaced_project_closes_the_dialog_even_when_the_comp_id_is_reused() { + closes_when(|s| { + let before = s.active_comp_id().unwrap(); + s.execute("file.newProject", json!({})).unwrap(); + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "duration": 2})).unwrap(); + assert_eq!(s.active_comp_id(), Some(before), "the new project reuses the comp's id"); + }); +} From c601829be19226aa12e87ea6672efc7a3561857e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 21 Oct 2026 14:57:06 -0400 Subject: [PATCH 0283/1386] 3D: drop the NewPrimitive variant's doc comment (#1734) --- crates/ui-egui/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 783f07798..18d30f184 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,7 +72,6 @@ pub enum Dialog { ViewOptions, CameraSettings, LightSettings, - /// Layer ▸ New ▸ 3D Primitive… (`panels::primitive_dialog`). NewPrimitive, KeyVelocity, KeyInterpolation, From ad8b6acb6d6f2c8bf356a1c40958247571c1b058 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:24 +0900 Subject: [PATCH 0284/1386] =?UTF-8?q?i18n(ja):=20panels=20and=20dialogs=20?= =?UTF-8?q?(300=20rows;=2027%=20=E2=86=92=2039%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog behind the menu bar translated the dialogs and Preferences, but much of the work area was still English: panel tabs, form fields, panel context menus, the tracker and graph controls, text-panel options, the File panel's footers and the credits. Add 300 rows to the Japanese catalog — and the matching Simplified and Traditional Chinese sources the tests keep it in step with (the other languages may lag, an untranslated row falls back to English): - panel tabs (dock.rs / dock_ui.rs titles), the Project panel's columns, footer tooltips and column menu; - form and dialog labels and choice options (the forms the panels open — import, interpret footage, render queue, trackers, text animators…); - panel context menus (timeline layer/property menus, graph editor); - the tracker, graph editor, text-panel, animator and Content-Aware Fill labels; - the Preview panel's transport tooltips, credits table headers and sort options, the composition-settings tabs and the About links; - the engine's text-animator menu labels, added as a data source like the settings pages (they are interface text the panels draw). Strings are wrapped where they are drawn with the existing `tr`/`tr_args` mechanism (the label helpers already ask the catalog, so the data tables need no new ids); the catalog tests keep the three complete languages listing the same sources, reject duplicate or empty rows and keep the placeholder counts. A new test assertion in `japanese_rows_translate_the_panels` pins a sample of this step. The panel catalogs are now 993 Japanese and 995 Chinese rows (the two Chinese catalogs are the complete source list); with the 628-row menu catalogs Japanese is 1,621 of ≈ 4,200 interface strings (39%, up from 27%). Still English: effect parameter names (≈ 1,650 registry names, drawn from the model, not the catalogs), Home-screen literals, tooltips drawn from engine data, engine error messages, and the rows other languages lack. Docs: localization-parity (header, per-language table, overall share, revision history), ROADMAP (language table, progress log, revision history). --- ROADMAP.md | 23 +- crates/ui-egui/src/credits.rs | 8 +- crates/ui-egui/src/dock_ui.rs | 4 +- crates/ui-egui/src/i18n/ui.rs | 913 +++++++++++++++++++++ crates/ui-egui/src/panels/comp_settings.rs | 2 +- crates/ui-egui/src/panels/graph.rs | 1 + crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/project.rs | 12 +- crates/ui-egui/src/panels/tracker.rs | 2 +- docs/localization-parity.md | 29 +- 10 files changed, 962 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..76baa4c7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | -| French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| French | menus only | 15% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 25% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | -| German | menus only | 16% | +| Japanese | partial (menus, most of the panels) | 39% | +| German | menus only | 15% | | 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, 39%), Ukrainian (partial, 27%), Czech (partial, 27%) and Russian (menus only, 15%). No 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-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of + ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). - 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 @@ -154,6 +158,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 77357e876..72e5f30ae 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..fb4a2d2e0 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -206,7 +206,7 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::tr(p.title()).to_string()); let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +570,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::tr(p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79c..e27945f70 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -825,6 +825,309 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1565,6 +1868,306 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("Frame Rate", "帧速率"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("3D Renderer", "3D 渲染器"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Search effects and presets", "搜索效果和预设"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "创建摄像机"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2300,6 +2903,306 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("Frame Rate", "幀率"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("3D Renderer", "3D 轉譯器"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Search effects and presets", "搜尋效果與預設集"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "建立攝影機"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4447,6 +5350,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock.rs", include_str!("../dock.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), @@ -4504,6 +5408,8 @@ mod tests { ("engine settings pages", include_str!("../../../engine/src/prefs.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/commands/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; /// Call shapes that hand a string literal straight to the user. @@ -4658,6 +5564,13 @@ mod tests { fn japanese_rows_translate_the_panels() { set_current("ja"); assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..04c9f20ec 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -259,7 +259,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, crate::i18n::tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 0b372c0a0..50ba90c64 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -245,6 +245,7 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; + let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..95e27da93 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -42,7 +42,8 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tr(tip)).clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c4..30d18b722 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -475,7 +475,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -981,7 +987,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); if resp.clicked() { @@ -1106,7 +1112,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..87f59a33a 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -615,7 +615,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = egui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..f35bcc74c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -63,16 +63,16 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | -| Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | -| French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | -| Portuguese (Brazil) | `pt-br` | 1,024 (≈ 26%): menus 624 rows, panels 400 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | +| French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,034 (≈ 27%): menus 624 rows, panels 410 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | -| German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | +| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,14 +80,14 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | -| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | +| Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 18%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 29%, Japanese 27%, Brazilian Portuguese 26%, Spanish, French, +**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages +(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). @@ -103,6 +103,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | From 714b6c29b02716dbd64ff02bd132998fa9e2600f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:46:55 -0400 Subject: [PATCH 0285/1386] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ sonar-project.properties | 4 ---- 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/sonarcloud.yml delete mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 66faf29c3..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,4 +0,0 @@ -sonar.projectKey=storytold_craft-launcher -sonar.organization=storytold -sonar.sources=. -sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/** From cee4c16c8ad53621aaf25c037564e77a2dd37090 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:34 -0400 Subject: [PATCH 0286/1386] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..23a37f67e --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_effectcraft +sonar.organization=storytold From 50033511862bf679ecff36e03512bb8209130b81 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:06:49 -0400 Subject: [PATCH 0287/1386] security: enforce HTTPS on curl downloads (SonarCloud S6506) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f149e663d..e32d66aca 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -115,7 +115,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi From 887fe3a3a85e28f007f3a1861b3cc54d70867488 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:14:27 -0400 Subject: [PATCH 0288/1386] security: use crypto.randomUUID for temp file suffix (SonarCloud S2245) Replace Math.random() with crypto.randomUUID() when generating the unique suffix for the atomic write-then-move temp file. The value isn't security- sensitive, but a CSPRNG gives a stronger uniqueness guarantee and resolves SonarCloud javascript:S2245. --- apps/effectcraft-web/js/host.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 5c438550e..658a5199d 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -495,7 +495,7 @@ export async function cacheRead(name) { 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 tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name; const fh = await dir.getFileHandle(tmp, { create: true }); if (fh.createSyncAccessHandle) { const h = await fh.createSyncAccessHandle(); From 798339669252c5ace2c45b48a277b425527c8ce6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 17:20:35 -0400 Subject: [PATCH 0289/1386] fix: JS reliability bugs (SonarCloud S1764, S9383, S4275) - scriptui.js: replace the self-comparison NaN check (o.max === o.max) with the explicit Number.isNaN(o.max) (S1764). - sw.js: mark the fire-and-forget cache.put() with void and a .catch() so the promise rejection is handled (S9383). - prelude.js: return __err(...) from the activeCamera getter so every path yields a value (S4275); __err always throws, so behavior is unchanged. --- apps/effectcraft-web/web/sw.js | 2 +- crates/expr/src/prelude.js | 2 +- crates/script/src/scriptui.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 8c7efc2e5..c5605c81f 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -38,7 +38,7 @@ self.addEventListener("fetch", (event) => { const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }); if (hit) return hit; const res = await fetch(req); - if (res.ok && res.type === "basic") cache.put(req, res.clone()); + if (res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {}); return res; })(), ); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4da7bc63b..df6ed3ff4 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -461,7 +461,7 @@ Comp.prototype = { var l = this.layer(i); if (l.__info[10] === 'Camera' && l.active) return l; } - __err('Composition "' + this.name + '" has no active camera'); + return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index af1e14b5e..52c65efb6 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -840,7 +840,7 @@ function __uiJson(c, inherited) { o.value = Number(c.value) || 0; o.min = Number(c.minvalue) || 0; o.max = Number(c.maxvalue); - if (!(o.max === o.max)) o.max = 100; + if (Number.isNaN(o.max)) o.max = 100; } if (c.items) { o.items = c.items.map(function (it) { return String(it.text); }); From 7d68a74ffc1bcdff943936bcb9b36047f28b48eb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:40 +0700 Subject: [PATCH 0290/1386] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9dc985ee1..5fa28f0bb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ EffectCraft icon: an engraved unicorn on a pink tile +

+

EffectCraft

From a59e7ad67f0c31159a1b4c029e218ccb78535e28 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:58:38 +0200 Subject: [PATCH 0291/1386] Timeline: assert the full search-row hierarchy for the elided Contents, add docstrings The search test now pins every row (layer, Contents, group, path/fill/ Transform) so a future regression that re-adds the inner Contents row or reorders the flattening fails loudly. Docstrings cover the functions the diff touches (group_row, all, rec, push_group) for CodeRabbit's docstring-coverage pre-merge check. --- crates/ui-egui/src/panels/timeline.rs | 49 +++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index fc09d30d0..d122f5034 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -747,12 +747,17 @@ fn display_name(app: &EffectcraftApp, l: &Layer) -> String { /// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group /// brings its whole contents. fn search_rows(l: &Layer, q: &str) -> Vec { + /// The row for a group in search results: depth, its effect's enabled flag, and the eye + /// switch for layer-style groups (not Blending Options). fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row { let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled); let eye = (parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled); Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } } } + /// Every visible child of `g` at `depth`, groups recursed into whole — a matching group + /// brings its contents. A shape group's inner Contents is elided (as in AE): no row, and + /// its items list at the same depth as the group itself. fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec) { for c in &g.children { match c { @@ -767,6 +772,9 @@ fn search_rows(l: &Layer, q: &str) -> Vec { } } } + /// The children of `g` that match `q`: a name-matching group brings its whole contents + /// (`all`); otherwise the search recurses and keeps only subtrees with hits. A shape + /// group's inner Contents is searched through without a row of its own (as in AE). fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec) { for c in &g.children { match c { @@ -1175,6 +1183,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec, f: &dyn Fn(&Property) -> boo } } +/// The rows for the children of `g` at `depth`: groups recurse into when their twirl-down is +/// in `open`, text animators list their selectors and properties directly (as in AE), and a +/// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items +/// list directly under the group. fn push_group(rows: &mut Vec, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -4428,8 +4440,9 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } - /// A shape group's inner Contents (AE's elided `ADBE Vectors Group`) draws no row: a search - /// finds the group's items directly, and the only "Contents" row is the layer's. + /// A shape group's inner Contents (AE's elided `ADBE Vectors Group`) draws no row: the + /// search shows the full row hierarchy with the group's items — path, fill and Transform — + /// listing directly under the group, and the only "Contents" row the layer's own. #[test] fn shape_group_inner_contents_is_elided() { let mut s = effectcraft_engine::Session::default(); @@ -4437,10 +4450,34 @@ mod tests { s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); let mut app = EffectcraftApp::new(s); app.ui.timeline.search = "rect".into(); - let rows = labels(&app); - assert!(rows.iter().any(|r| r.trim() == ">Rectangle 1"), "the shape group matches: {rows:?}"); - assert!(rows.iter().any(|r| r.trim() == ">Rectangle Path 1"), "its path lists directly under it: {rows:?}"); - assert_eq!(rows.iter().filter(|r| r.trim() == ">Contents").count(), 1, "only the layer's Contents: {rows:?}"); + assert_eq!( + labels(&app), + [ + "Shape Layer 1", + " >Contents", + " >Rectangle 1", + " >Rectangle Path 1", + " Size", + " Position", + " Roundness", + " >Fill 1", + " Composite", + " Fill Rule", + " Color", + " Opacity", + " >Transform", + " Anchor Point", + " Position", + " Scale", + " Skew", + " Skew Axis", + " Rotation", + " Opacity", + ] + .iter() + .map(|r| r.to_string()) + .collect::>() + ); } #[test] From 3f4ffdc0fc7ab765cb894c192f2ed7503381cbaf Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 18:41:26 +0530 Subject: [PATCH 0292/1386] engine: a keyframe on the out point no longer warns (#2084) --- crates/engine/src/commands/prop.rs | 4 +++- crates/engine/src/tests_key_time.rs | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..24598a3a7 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -179,7 +179,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: & pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { let ct = layer.comp_time(t); let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); - if !layer.is_active_at(ct) { + // A key exactly on the out point ends a fade normally: it is outside the half-open visible + // range, but it is not a caller mistake, so it does not warn (#2084). + if ct < layer.in_point || ct > layer.out_point { out["warning"] = json!(format!( "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", ct.seconds(), diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests_key_time.rs index 4d4ab367b..d42d6d4a0 100644 --- a/crates/engine/src/tests_key_time.rs +++ b/crates/engine/src/tests_key_time.rs @@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() { assert!(selected.get("warning").is_some()); let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap(); assert_seconds(&moved["compTime"], 20.0); - assert!(moved.get("warning").is_some(), "out point is exclusive"); + assert!(moved.get("warning").is_none(), "a key on the out point is accepted"); + let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap(); + assert_seconds(&past["compTime"], 20.1); + assert!(past.get("warning").is_some(), "a key past the out point warns"); } #[test] From 963f489eab4947936cf770f8e01710c50298f69b Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 18:47:59 +0530 Subject: [PATCH 0293/1386] engine: resolve effect match-name paths in prop_ref (#2064) --- crates/engine/src/commands/prop.rs | 7 +++++-- crates/engine/src/tests_effects.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..596164b42 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay }); } let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?; - let pr = layer.props.prop(path).ok_or_else(|| { - let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); + // `effects/[#n]/` is how `essential.list`, presets and Copy write an + // effect property; turn it into the uid path `PropGroup::prop` parses (#2064). + let resolved = super::resolve_effect_path(layer, path); + let pr = layer.props.prop(&resolved).ok_or_else(|| { + let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); if group.is_some() { bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) } else { diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 18fcc6f84..1ea57f4cf 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// #2064: `prop.addKey` accepts the `effects/[#n]/` path that +/// `essential.list` reports. +#[test] +fn add_key_resolves_effect_match_name_paths() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "Slider Control"); + apply(&mut s, a, "Slider Control"); + let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value { + KV::Scalar(v) => v, + ref other => panic!("not a scalar: {other:?}"), + }; + for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] { + let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap(); + assert_eq!(reply, json!(1), "{path}"); + let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap(); + assert_eq!(got["value"], json!(value), "{path}"); + assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}"); + assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}"); + } +} + /// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path, /// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect. #[test] From ffe3c913e1aca5548cdfa0c952bc01b7c3ec336d Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:05:30 +0530 Subject: [PATCH 0294/1386] ui: scrub hot text from the slider range (#2097) --- crates/ui-egui/src/panels/effect_controls.rs | 5 +---- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/widgets.rs | 21 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 03b3a1ac4..79f414afd 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -337,10 +337,7 @@ fn prop_row( ParamUi::Percent => "%", _ => "", }; - let speed = match &prop.ui { - ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0), - _ => 0.5, - }; + let speed = widgets::slider_scrub_speed(&prop.ui); let (vr, nv, _) = widgets::hot_number_at( ui, pos2(vx, cy - 9.0), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..af4f7e28e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4105,7 +4105,7 @@ fn value_editor( match value { Value::Scalar(v) => { let (min, max, dec, speed) = match &prop.ui { - ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, ((max - min) / 400.0).clamp(0.01, 10.0)), + ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)), ParamUi::Percent => (-1e6, 1e6, 1, 0.5), _ => (-1e9, 1e9, 1, 1.0), }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..d3d59e2cc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2,6 +2,8 @@ use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use effectcraft_project::ParamUi; + use crate::automation::Registry; use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -145,6 +147,16 @@ pub fn proportional_scrub(m: egui::Modifiers) -> bool { m.command && m.alt } +/// How far one pointer pixel scrubs a hot-text field: the property's slider (soft) range over +/// 300 px. The Timeline and Effect Controls share this so they cannot drift; a large hard range +/// only allows typing big values, it must not make dragging coarse (#2097). +pub fn slider_scrub_speed(ui: &ParamUi) -> f64 { + match ui { + ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0), + _ => 0.5, + } +} + /// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every /// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames), /// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished). @@ -666,6 +678,15 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { mod tests { use super::*; + /// #2097: the drag speed comes from the slider range, not a much wider hard range. + #[test] + fn slider_scrub_speed_uses_the_slider_range() { + let slider = |min, max, slider_min, slider_max| ParamUi::Slider { min, max, slider_min, slider_max, decimals: 1 }; + assert!((slider_scrub_speed(&slider(0.0, 10_000.0, 0.0, 1_000.0)) - 1_000.0 / 300.0).abs() < 1e-9); + assert!((slider_scrub_speed(&slider(0.0, 1.0e9, 0.0, 1.0)) - 1.0 / 300.0).abs() < 1e-9); + assert_eq!(slider_scrub_speed(&ParamUi::Number), 0.5); + } + /// #488: an inline editor selects its whole text only as it opens. Should it be without the /// keyboard for a frame later (an input method, another handler), it takes the keyboard back /// with the caret where the user put it instead of selecting everything again, which made From 7fe051389461305c9002fcd04ca6bbec789559c1 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:13:36 +0530 Subject: [PATCH 0295/1386] engine: split layers above the original by default (#2083) --- crates/engine/src/prefs.rs | 3 ++- crates/engine/src/tests_m145.rs | 12 ++++++------ docs/preferences.md | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a2ce579ea..10f7418e7 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -110,7 +110,8 @@ page!(General { /// Synchronize Time of All Related Items. sync_time_related_items: bool = true, expression_pick_whip_compact: bool = true, - create_split_layers_above: bool = false, + /// Create Split Layers Above Original Layer (After Effects' default). + create_split_layers_above: bool = true, use_system_color_picker: bool = false, /// Number of projects in File ▸ Open Recent. recent_items: u32 = 10, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index fe0a16936..68b897617 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() { let mut s = session(); let a = solid(&mut s, "#ff0000"); s.execute("time.set", json!({"time": 1.0})).unwrap(); - let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); - let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![a, below], "off (default): the new part goes below"); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap(); let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![above, a]); + assert_eq!(order, vec![above, a], "on (default): the new part goes above"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap(); + let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![a, below]); } #[test] diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a1..2c8f987ae 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -91,7 +91,8 @@ Commands (CLI, MCP, control channel): one and in the comps that nest it (through the precomp layers' timing) - `general.expressionPickWhipCompact`: off, the pick whip writes match names (`thisComp.layer("A")("transform")("opacity")`) -- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer +- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer (on by default, + as in After Effects) - `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent - `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`: how much of a position motion path the viewer draws (all, none, N seconds or N keyframes From 3306bf229681e41c2593f49bac246626084e7134 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:15:34 +0530 Subject: [PATCH 0296/1386] ui: Spacebar hand pans timeline rows vertically (#2083) --- crates/ui-egui/src/panels/timeline.rs | 17 ++++--- crates/ui-egui/tests/ui_timeline_pan.rs | 61 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_pan.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..f79939c88 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3330,8 +3330,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); - // Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when - // zoomed in (registered last, so it takes the drag from the bars, keys and markers there). + // Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the + // time graph in time when zoomed in (registered last, so it takes the drag from the bars, + // keys and markers there). if super::space_hand(&ctx) { let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y)); let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag()); @@ -3339,10 +3340,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if hand.hovered() || hand.dragged() { ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } - if hand.dragged() && app.ui.timeline.pps.is_some() { - let start = app.ui.timeline.start; - let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); - app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); + if hand.dragged() { + let dy = ui.input(|i| i.pointer.delta().y); + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); + if app.ui.timeline.pps.is_some() { + let start = app.ui.timeline.start; + let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); + app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); + } } } diff --git a/crates/ui-egui/tests/ui_timeline_pan.rs b/crates/ui-egui/tests/ui_timeline_pan.rs new file mode 100644 index 000000000..8523ded30 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_pan.rs @@ -0,0 +1,61 @@ +//! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well +//! as the time graph horizontally (#2083). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10 s comp with 40 solids, enough rows to scroll. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + let mut top = 0; + for i in 0..40 { + top = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + } + s.execute("layer.select", json!({"layers": []})).unwrap(); + let app = EffectcraftApp::new(s); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app); + h.run_steps(4); + (h, top) +} + +fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { + h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn spacebar_drag_pans_the_rows_vertically() { + let (mut h, layer) = harness(); + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); + let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").rect; + let from = pos2(bar[0] + 40.0, bar[1] + bar[3] / 2.0); + let timing = |h: &Harness<'_, EffectcraftApp>| { + let l = &h.state().session.active_comp().unwrap().layers[0]; + (l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()) + }; + let before = timing(&h); + space(&mut h, true); + drag(&mut h, from, from - vec2(0.0, 180.0)); + space(&mut h, false); + let y = h.state().ui.timeline.scroll_y; + assert!(y > 100.0, "the rows scrolled vertically: {y}"); + assert_eq!(h.state().ui.timeline.start, 0.0, "the time graph did not pan sideways"); + assert_eq!(timing(&h), before, "the layer under the hand did not move"); +} From d2fae0a1449b8ef1ac233fb031f4595694d4d940 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 22 Oct 2026 21:19:20 +0800 Subject: [PATCH 0297/1386] fix: bound Classic 3D depth-of-field blur and reject non-finite focus distances (#2092) The camera's Set Focus Distance to Layer command compared only 'd <= 0.0', which NaN passes, and two finite but extreme positions can overflow their difference to infinity; a stored non-finite focus distance serializes as null and leaves the project unsavable. Reject it, fall back to the zoom plane in the camera evaluation, and count a non-finite focus or depth as in focus. The Classic 3D blur radius itself was unbounded: a 0%-scaled or very distant layer scales it by up to 1e9, and the padded blur buffer sized from it aborts the process. Cap each plane's radius to a padded buffer that fits the renderer's 16 Mpx layer budget, don't blur planes whose pixels cover under a thousandth of a comp pixel, saturate the padded sizes, and refuse padded buffers past the project's maximum layer size. Regression tests for each guard. --- crates/engine/src/commands/focus.rs | 6 + crates/engine/src/tests_tail.rs | 64 +++++++++++ crates/gpu/src/bokeh.rs | 5 +- crates/raster/src/lib.rs | 6 +- crates/render/src/three_d/camera.rs | 40 ++++--- crates/render/src/three_d/compose.rs | 80 +++++++++++--- crates/render/src/three_d/tests.rs | 157 ++++++++++++++++++++++++++- 7 files changed, 320 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/commands/focus.rs b/crates/engine/src/commands/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/commands/focus.rs +++ b/crates/engine/src/commands/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches. #[test] fn numeric_string_resolves_as_id() { diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index ac07448c9..9150fa6b4 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index f17602829..c01b3d3c2 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -138,9 +138,11 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// Copy with `pad` transparent pixels on every side. Sizes saturate rather than wrap around, + /// but a saturated size can't be allocated: callers bound `pad`. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let (w, h) = (self.width.saturating_add(pad.saturating_mul(2)), self.height.saturating_add(pad.saturating_mul(2))); + let mut out = Image::new(w, h); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5e1f983b2..bfdc4fb7b 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -313,22 +314,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 6cf87e70e..416eada08 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -290,12 +290,43 @@ fn prepare_with<'a>( } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), so a blur never needs more memory than a large layer; +/// twice the buffer when that is larger, so a big buffer can still be blurred. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -309,31 +340,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -342,16 +390,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 9f1dbb344..ecaaa4d9a 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -347,7 +352,7 @@ fn shadow_darkness_and_accepts_off() { // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -423,9 +428,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] From 52bfb9c4bbca6879e196039dbb419c2c4351a872 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:39:10 +0200 Subject: [PATCH 0298/1386] Reject non-hex characters such as '+' in Rgba::from_hex Found while testing crates/color/src/lib.rs:40-47 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5545f390d..a8957bcb9 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)?) }), @@ -262,6 +268,14 @@ mod tests { assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}"); } + #[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 { From 2d360d9a83d5ca01bd655ed26d36945b49816a8a Mon Sep 17 00:00:00 2001 From: midasdf Date: Thu, 22 Oct 2026 23:08:39 +0900 Subject: [PATCH 0299/1386] Text: the Han fallback test accepts an earlier Chinese family (#2039) The macOS fallback test selected the first installed family from the Simplified list, so a Mac that also has PingFang SC (or Microsoft YaHei / Noto Sans CJK SC) picked that one and then failed an assertion that only accepted the three macOS faces. Accept any family from the list -- every one is Chinese -- and check that each installed macOS face covers the shared ideographs instead. Also exercise fallback_for itself under a zh-CN request, which the direct resolve_in lookup bypassed, and serialise the process-global locale with the layout CJK test so a cached family order cannot leak between them. Tests: effectcraft-text 75 lib + 3 integration passed; effectcraft-render 233 passed, 1 ignored; cargo fmt --all -- --check and cargo clippy -p effectcraft-text --all-targets -- -D warnings clean. --- crates/text/src/fonts.rs | 57 ++++++++++++++++++++++++++++++++++++++- crates/text/src/layout.rs | 2 ++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5e057811c..c5c5cd58d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -744,6 +744,12 @@ const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sa /// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]). static REQUESTED_CJK_LOCALE: RwLock> = RwLock::new(None); +/// Serialises the tests that change the process-global CJK locale with the tests that read it +/// through [`fallback_for`] (the layout tests), which would otherwise cache the other order's +/// families. +#[cfg(test)] +pub(crate) static TEST_CJK_LOCALE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Ask CJK font fallbacks to prefer `tag`'s language for Han ideographs instead of the locale /// environment, so a Chinese interface is drawn with a Chinese face on a machine whose locale is /// Japanese (and the other way round). The interface sets this from its language setting; an empty @@ -989,6 +995,15 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + /// Clears the requested CJK locale when a test that set it ends, panic or not. + struct LocaleReset; + + impl Drop for LocaleReset { + fn drop(&mut self) { + set_cjk_locale(""); + } + } + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -1164,6 +1179,7 @@ mod tests { /// loaded a CJK font first. Skipped where no family from the list is installed. #[test] fn cjk_falls_back_to_a_system_family() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); scan_system(); let inter = resolve("Inter", "Bold").face; let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()); @@ -1284,6 +1300,7 @@ mod tests { #[test] fn the_han_order_follows_the_cjk_locale() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); // Chinese first for any Chinese tag, whether or not it names a script. for tag in ["zh", "zh-CN", "zh_Hans", "ZH-Hant-TW.UTF-8", "zh_MO"] { assert_eq!(han_order_for(tag)[0], FallbackScript::Han, "{tag}"); @@ -1301,6 +1318,7 @@ mod tests { #[test] fn the_chinese_family_list_follows_the_script_the_locale_asks_for() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] { assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}"); } @@ -1342,11 +1360,48 @@ mod tests { // exhaustive search would have found (Hiragino Sans), and it covers the shared ideographs. let (family, resolved) = SIMPLIFIED_CHINESE_FAMILIES.iter().find_map(|f| resolve_in(f, "Regular").map(|r| (*f, r))).expect("an installed Simplified Chinese family"); - assert!(installed.iter().any(|i| i.eq_ignore_ascii_case(family)), "{family} is not a macOS Chinese face"); + // An earlier name can win the walk when this Mac has it as well (`PingFang SC` on an older + // install, or Microsoft YaHei / Noto Sans CJK SC through Office or Homebrew). That is just + // as valid a Chinese fallback as the macOS trio; only the Japanese face would be a + // regression. Each installed macOS face is on the list (checked above) and covers the + // shared ideographs. assert!(!JAPANESE_FAMILIES.iter().any(|j| j.eq_ignore_ascii_case(family)), "{family} is a Japanese family"); for c in ['骨', '直', '日'] { assert!(face(resolved.face).has_char(c), "{family} cannot draw {c}"); } + for name in installed { + let resolved = resolve_in(name, "Regular").expect("installed above"); + for c in ['骨', '直', '日'] { + assert!(face(resolved.face).has_char(c), "{name} cannot draw {c}"); + } + } + } + + /// #453 under a Chinese interface: Han text in a Latin-only family falls back through the Han + /// list, in the locale's order, to an installed Simplified Chinese family; it must not reach the + /// Japanese face the exhaustive search would pick. The direct family lookup above would not + /// catch a `fallback_for` that skipped `han_order`. Skipped where no Simplified family is + /// installed. + #[test] + fn chinese_han_text_falls_back_to_a_simplified_chinese_family() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); + scan_system(); + if !SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()) { + eprintln!("no Simplified Chinese family installed; skipping"); + return; + } + set_cjk_locale("zh-CN"); + let _reset = LocaleReset; + let inter = resolve("Inter", "Regular").face; + for c in ['骨', '直', '日', '真'] { + let f = face(fallback_for(c, inter)); + assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style); + assert!( + SIMPLIFIED_CHINESE_FAMILIES.iter().any(|n| n.eq_ignore_ascii_case(&f.info.family)), + "{c} → {} is not a Simplified Chinese family", + f.info.family + ); + } } #[test] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 770afb965..47d786241 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1289,6 +1289,8 @@ mod tests { /// (#453). Skipped where no CJK face is installed, like the font-fallback tests. #[test] fn cjk_text_falls_back_to_a_face_with_the_glyph() { + // The CJK locale is process-global; keep a font test from changing it while this lays out. + let _locale = crate::fonts::TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); let primary = fonts::resolve("Inter", "Regular").face; if !fonts::face(fonts::fallback_for('日', primary)).has_char('日') { eprintln!("no Japanese face installed; skipping"); From 5300c9fa87d564c344e41c8a7c5f4339754f7a6d Mon Sep 17 00:00:00 2001 From: midasdf Date: Thu, 22 Oct 2026 23:10:58 +0900 Subject: [PATCH 0300/1386] i18n(ja): add the missing panel rows and recount localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the panel/dialog step: the composition-settings tabs translate "Basic" and "Advanced" through `tr(variable)` and the Preview transport translates "Play/Stop", but none of the three had a catalog row, so they stayed English. Add them to the Japanese, Simplified and Traditional Chinese catalogs and pin them in `japanese_rows_translate_the_panels`. The shared "Type" key means "kind" in the Project column and the Camera Settings dialog, so the Chinese rows read 类型/類型 instead of 文字 ("text"). Keep the graph button bar's automation labels English: translate only the drawn label and tooltip, and translate the stored English strings when the hidden-button menu draws them. Recount the docs from the catalogs: 628 menu rows, 996 Japanese and 998 Chinese panel rows; count Simplified Chinese's 306 effect names in its numerator (46%) and average the ten After Effects languages the table actually names (21%, not the stale 27%). Update the measurement basis, the cross-references in gaps.md and target-app-parity.md, and ROADMAP. --- ROADMAP.md | 15 ++++++----- crates/ui-egui/src/i18n/ui.rs | 18 +++++++++++-- crates/ui-egui/src/panels/graph.rs | 11 ++++---- docs/gaps.md | 4 +-- docs/localization-parity.md | 43 +++++++++++++++--------------- docs/target-app-parity.md | 2 +- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 76baa4c7d..8b32cc87f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%, Simplified Chinese 29% → 46%, overall 18% → 21%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 21% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -85,7 +85,7 @@ UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/lo | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | | Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | @@ -117,10 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log -- 2026-10-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, - tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of - ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + Simplified Chinese is 46% with its 306 effect names. - 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 @@ -158,7 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted (Japanese 27% → 39%, Simplified Chinese 29% → 46% with its effect names, Traditional Chinese 29% → 39%, overall 18% → 21%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index e27945f70..d8b982dbf 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -120,6 +120,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -392,6 +393,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -1003,6 +1005,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "縦横比を固定"), ("Frame Rate presets", "フレームレートのプリセット"), ("Background Color", "背景色"), + ("Basic", "基本"), ("Shutter Angle", "シャッター角度"), ("Shutter Phase", "シャッター位相"), ("Samples Per Frame", "フレームあたりのサンプル数"), @@ -1164,6 +1167,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1427,6 +1431,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1557,7 +1562,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -2046,6 +2051,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "锁定长宽比"), ("Frame Rate presets", "帧速率预设"), ("Background Color", "背景颜色"), + ("Basic", "基本"), ("Shutter Angle", "快门角度"), ("Shutter Phase", "快门相位"), ("Samples Per Frame", "每帧采样数"), @@ -2204,6 +2210,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -2467,6 +2474,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2597,7 +2605,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -3081,6 +3089,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "鎖定長寬比"), ("Frame Rate presets", "幀率預設集"), ("Background Color", "背景顏色"), + ("Basic", "基本"), ("Shutter Angle", "快門角度"), ("Shutter Phase", "快門相位"), ("Samples Per Frame", "每幀取樣數"), @@ -5571,6 +5580,11 @@ mod tests { assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 50ba90c64..076ad725b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -218,7 +218,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -245,15 +245,16 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; - let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -265,7 +266,7 @@ fn text_button( Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() diff --git a/docs/gaps.md b/docs/gaps.md index fd92c22a4..7507a06d7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 21% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 628 rows each); panels in 6 (ja 996 rows, zh-hans and zh-hant 998 each, uk 524, pt-br 417, cs 518); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f35bcc74c..a03d72e1c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,624 of ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,626 (Simplified Chinese 1,932 with its 306 effect names, 46%); previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -19,12 +19,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all - fixed entries (624 catalog rows each at 470ea7a3, counted). The generated Effect menu is not part of that fixed tree: its entries + fixed entries (628 catalog rows each at `ad8b6acb`, counted). The generated Effect menu is not part of that fixed tree: its entries and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and - Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified and + Traditional Chinese (998 each), Ukrainian (524), Brazilian Portuguese (417), Czech (518) and + Spanish (513); counted at `ad8b6acb`. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts @@ -53,25 +53,26 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 22 October 2026 at `ad8b6acb` +by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and `i18n/ui.rs`: 628 +menu rows and 998 panel/dialog sources per complete language, 996 in Japanese). The denominator, +**≈ 4,200 user-visible strings**, is a rounded estimate: the 628 fixed menu entries, the 998 +panel/dialog sources, 306 effect names, ≈ 1,650 distinct effect parameter names and ≈ 620 other +unwrapped interface strings (Home screen literals, engine-built tooltips, error and status +messages). It counts distinct strings, not catalog rows; engine messages are only partly counted, +so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,932 (≈ 46%): menus 628 rows, panels 998, effect names 306 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,624 (≈ 39%): menus 628 rows, panels 996 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,20 +81,20 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,626 (≈ 39%): menus 628 rows, panels 998 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | | Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, -German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 39%, Japanese 39%, Brazilian Portuguese 25%, +Spanish, French, German and Russian 15%, Korean and Italian 0%; 209 / 10, was ≈ 18% on 12 October). +This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -103,7 +104,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; Simplified Chinese reaches 1,932 (46%) with its 306 effect names, and the overall share is 21% | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa4524..80be7108f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 21% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 6, effects in Simplified Chinese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | From fdfa286079da535e5b8480fcd9a3b66efdd513b4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:46:14 +0000 Subject: [PATCH 0301/1386] Keep rgb_to_hsv and rgb_to_hsl hue within 0..1 Found while testing crates/color/src/lib.rs:133 (rgb_to_hsv) and :96 (rgb_to_hsl) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5545f390d..4a280627d 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -93,7 +93,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 +130,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 { @@ -262,6 +262,14 @@ mod tests { 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 srgb_roundtrip() { for i in 0..=100 { From 9e39aea45b5d21e02409bc04ddaf3f050ecd6d9d Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 23 Oct 2026 01:29:35 +0900 Subject: [PATCH 0302/1386] ROADMAP: the Chinese panel catalogs hold 998 rows, not 1,626 1,626 is the Traditional Chinese total translated-string count; the panel catalogs carry 998 source rows each (996 in Japanese), as docs/localization-parity.md measures. --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8b32cc87f..e3dc2792c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,7 +120,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). - 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of - ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 998 rows each, and Simplified Chinese is 46% with its 306 effect names. - 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 From 636f1cf73858c7005ad4eff4404e85931e993193 Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 23 Oct 2026 01:37:13 +0900 Subject: [PATCH 0303/1386] Text: the Japanese fallback test pins ja-JP (#2039) The test checked that Japanese text falls back to an installed Japanese family, but left the process-global CJK locale at the host's: on a zh-CN host with a Chinese face installed, han_order puts the Chinese families first for the shared ideographs and the Japanese-family assertion fails. Set ja-JP under the TEST_CJK_LOCALE lock and clear the request when the test ends, as the zh-CN test beside it does. Tests: effectcraft-text 75 lib + 3 integration passed; cargo fmt --all -- --check passed. --- crates/text/src/fonts.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c5c5cd58d..e41d28098 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1179,7 +1179,11 @@ mod tests { /// loaded a CJK font first. Skipped where no family from the list is installed. #[test] fn cjk_falls_back_to_a_system_family() { + // Japanese text must reach the Japanese list even on a Chinese-language host, where the + // ambient locale puts the Chinese families first for the shared ideographs. let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); + set_cjk_locale("ja-JP"); + let _reset = LocaleReset; scan_system(); let inter = resolve("Inter", "Bold").face; let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()); From 966e27969107278054b912bd35f31274d944ec1c Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:10:51 +0300 Subject: [PATCH 0304/1386] model: cap glTF buffer-less accessors in aggregate, check element offsets (#2139) The per-accessor count checks from #1235 already reject the audit's trigger (a MAT4 accessor with count 10^11); a regression test now pins it. What was left: accessors without a bufferView were capped at 16M components each, but nothing backs them, so one capped accessor referenced from many primitives allocated 64 MB per reference. The cap is now one budget for the whole file. Per-element offsets use checked arithmetic as well. --- crates/model/src/gltf.rs | 35 +++++++++++++++++++++++------------ crates/model/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index e17d014f6..e70dc4fc2 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -11,7 +11,7 @@ //! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets //! are ignored. -use std::sync::Arc; +use std::{cell::Cell, sync::Arc}; use serde_json::Value; @@ -112,6 +112,9 @@ fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option>) -> Result { j: &'a Value, buffers: Vec>, + /// Zero components that buffer-less accessors may still allocate, across the whole file: + /// nothing backs them, so many references to one capped accessor must not multiply (#2139). + zero_left: Cell, } fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { @@ -126,8 +129,9 @@ fn indices(v: &Value, k: &str) -> Vec { arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() } -/// The most components an accessor without a buffer view (all zeros) may have: nothing in the -/// file backs its count, so it is capped instead of checked against a buffer (#1235). +/// The most components accessors without a buffer view (all zeros) may have, together: nothing +/// in the file backs their counts, so they are capped instead of checked against a buffer +/// (#1235, #2139). const MAX_ZERO_COMPONENTS: usize = 1 << 24; /// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` @@ -150,6 +154,13 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { out } +/// Bytes of component `c` of element `i`, with checked offsets (the span was validated, but +/// nothing derived from the file is indexed unchecked). +fn component(buf: &[u8], base: usize, stride: usize, i: usize, c: usize, csize: usize) -> Result<&[u8]> { + let o = i.checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(c.checked_mul(csize)?)); + o.and_then(|o| buf.get(o..o.checked_add(csize)?)).ok_or_else(|| perr("accessor reads past its buffer")) +} + impl Doc<'_> { /// The accessor's element count and total components (`count * n`), checked; and its span in /// the buffer, validated before anything is allocated from the count. `None` for an @@ -161,16 +172,18 @@ impl Doc<'_> { }; let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; let Some(bv) = idx(acc, "bufferView") else { - if total > MAX_ZERO_COMPONENTS { - return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})"))); - } + let left = self.zero_left.get(); + let Some(rest) = left.checked_sub(total) else { + return Err(perr(format!("accessors without a bufferView have more than {MAX_ZERO_COMPONENTS} components ({total} requested, {left} left)"))); + }; + self.zero_left.set(rest); return Ok((count, total, None)); }; let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; let past = || perr("accessor reads past its buffer"); let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; - let elem = csize * n; + let elem = csize.checked_mul(n).ok_or_else(past)?; let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); if stride < elem { return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); @@ -214,8 +227,7 @@ impl Doc<'_> { // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out[i * n + c] = match ct { 5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]), 5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32, @@ -264,8 +276,7 @@ impl Doc<'_> { let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { - let o = base + i * stride + c * csize; - let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?; + let s = component(buf, base, stride, i, c, csize)?; out.push(match csize { 1 => s[0] as u32, 2 => u16::from_le_bytes([s[0], s[1]]) as u32, @@ -300,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< }; buffers.push(data); } - let d = Doc { j: &j, buffers }; + let d = Doc { j: &j, buffers, zero_left: Cell::new(MAX_ZERO_COMPONENTS) }; // Images → textures (decoded once per image; samplers set the wrap modes). let mut images: Vec>> = vec![]; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 7c98c1ae7..7a300e71e 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -564,3 +564,35 @@ fn gltf_strided_and_buffer_less_accessors_still_import() { let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); } + +#[test] +fn gltf_issue_2139_huge_mat4_count_is_an_error() { + // The audit's trigger (#2139): a MAT4 accessor with a count of 10^11, with and without a + // buffer view, must fail cleanly instead of sizing an allocation from the count. + let view = r#"{"buffer": 0, "byteLength": 64}"#; + for acc in [ + r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + r#"{"componentType": 5126, "type": "MAT4", "count": 100000000000}"#, + ] { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 1}"#, view, 64) + .replace(r#""count": 1}]"#, &format!(r#""count": 1}}, {acc}]"#)) + .replace(r#""nodes": [{"mesh": 0}]"#, r#""nodes": [{"mesh": 0, "skin": 0}], "skins": [{"joints": [0], "inverseBindMatrices": 1}]"#); + assert!(doc.contains("inverseBindMatrices"), "{doc}"); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{acc}"); + } +} + +#[test] +fn gltf_buffer_less_accessors_share_one_zero_budget() { + // Each buffer-less accessor is capped, but nothing backs any of them, so referencing a capped + // one from many primitives must not multiply the allocation (#2139). + let acc = r#"{"componentType": 5126, "type": "VEC3", "count": 5000000}"#; + let prims = [r#"{"attributes": {"POSITION": 0}}"#; 4].join(", "); + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3).replace(r#"[{"attributes": {"POSITION": 0}}]"#, &format!("[{prims}]")); + assert_eq!(doc.matches(r#""POSITION": 0"#).count(), 4); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("imported")); + assert!(e.to_string().contains("without a bufferView"), "{e}"); + // One such accessor read once still imports. + let doc = gltf_doc(acc, r#"{"buffer": 0}"#, 3); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_ok()); +} From ed532afe72d40a09ac4d736143ca5ef9d296cf13 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:12:38 +0200 Subject: [PATCH 0305/1386] Return None from Mat4::inverse for non-finite input Found while testing crates/geom/src/lib.rs:362-390 (pivot check at line 367) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index a03c9d46c..d86784f1e 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -360,6 +360,9 @@ impl Mat4 { } /// General inverse (Gauss-Jordan with partial pivoting). pub fn inverse(&self) -> Option { + if !self.0.iter().flatten().all(|v| v.is_finite()) { + return None; + } let mut a = self.0; let mut inv = Mat4::IDENTITY.0; for col in 0..4 { @@ -450,6 +453,16 @@ mod tests { (a - b).length() < 1e-9 } + #[test] + fn mat4_inverse_rejects_non_finite() { + for (r, c, v) in [(0, 0, f64::NAN), (2, 1, f64::NAN), (1, 3, f64::INFINITY)] { + let mut m = Mat4::IDENTITY; + m.0[r][c] = v; + assert!(m.inverse().is_none(), "{r},{c},{v}"); + } + assert!(Mat4::IDENTITY.inverse().is_some()); + } + #[test] fn layer_2d_maps_anchor_to_position() { let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0); From de4d8251520b4ae8b1148f4ea5286919e89b561e Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 02:18:57 +0900 Subject: [PATCH 0306/1386] i18n(ja): the blur, colour, distort, stylize and generate effect names (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 148 of the 307 ids. Names from AE 2026's Japanese interface and from Adobe's Japanese help, which is what settles the `CC` rows staying English and `Smart Blur` being `ブラー(詳細)`. Three rows rest on elimination and are called out in the PR. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 137 ++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5c6d09ba5..912150b18 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -440,18 +440,126 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that /// English name here rather than being left out. /// -/// The rows below were read from the installed After Effects 2026 (26.5) Japanese interface -/// (its generated Effect menu and its Effects & Presets panel). +/// The rows below come from AE 2026's Japanese interface where it was read directly (its Effect menu +/// and its Effects & Presets panel) and from Adobe's own Japanese help where the panel was not to +/// hand. A published glossary is used to cross-check, never as the only source: it normalises the +/// typography (half-width parentheses where AE draws full-width) and still carries CS6-era names for +/// the `CC` series, which AE 2026 leaves in English. const JAPANESE_EFFECTS: &[Row] = &[ ("ec.blur.bilateral", "Bilateral Blur", "ブラー(バイラテラル)"), ("ec.blur.cameralens", "Camera Lens Blur", "ブラー(カメラレンズ)"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "カメラぶれ除去"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), ("ec.blur.channel", "Channel Blur", "ブラー(チャンネル)"), ("ec.blur.compound", "Compound Blur", "ブラー(合成)"), ("ec.blur.directional", "Directional Blur", "ブラー(方向)"), ("ec.blur.fastbox", "Fast Box Blur", "高速ボックスブラー"), ("ec.blur.gaussian", "Gaussian Blur", "ブラー(ガウス)"), ("ec.blur.radial", "Radial Blur", "ブラー(放射状)"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "インターレースのちらつき削減"), + ("ec.blur.sharpen", "Sharpen", "シャープ"), + ("ec.blur.smart", "Smart Blur", "ブラー(詳細)"), + ("ec.blur.unsharp", "Unsharp Mask", "アンシャープマスク"), + ("ec.color.autocolor", "Auto Color", "自動カラー補正"), + ("ec.color.autocontrast", "Auto Contrast", "自動コントラスト"), + ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), + ("ec.color.blackwhite", "Black & White", "白黒"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "色を変更"), + ("ec.color.changetocolor", "Change to Color", "他のカラーへ変更"), + ("ec.color.channelmixer", "Channel Mixer", "チャンネルミキサー"), + ("ec.color.colorama", "Colorama", "コロラマ"), + ("ec.color.colorbalance", "Color Balance", "カラーバランス"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "カラーバランス(HLS)"), + ("ec.color.colorlink", "Color Link", "カラーリンク"), + ("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"), + ("ec.color.curves", "Curves", "トーンカーブ"), + ("ec.color.equalize", "Equalize", "平均化(イコライズ)"), + ("ec.color.exposure", "Exposure", "露光量"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "ガンマ/ペデスタル/ゲイン"), + ("ec.color.huesaturation", "Hue/Saturation", "色相/彩度"), + ("ec.color.leavecolor", "Leave Color", "色抜き"), + ("ec.color.levels", "Levels", "レベル補正"), + ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("ec.color.photofilter", "Photo Filter", "レンズフィルター"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), + ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "シャドウ・ハイライト"), + ("ec.color.tint", "Tint", "色かぶり補正"), + ("ec.color.tritone", "Tritone", "トライトーン"), + ("ec.color.vibrance", "Vibrance", "自然な彩度"), + ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), + ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), + ("ec.distort.bulge", "Bulge", "バルジ"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "コーナーピン"), + ("ec.distort.displacementmap", "Displacement Map", "ディスプレイスメントマップ"), + ("ec.distort.liquify", "Liquify", "ゆがみ"), + ("ec.distort.magnify", "Magnify", "ズーム"), + ("ec.distort.meshwarp", "Mesh Warp", "メッシュワープ"), + ("ec.distort.mirror", "Mirror", "ミラー"), + ("ec.distort.offset", "Offset", "オフセット"), + ("ec.distort.opticscompensation", "Optics Compensation", "レンズ補正"), + ("ec.distort.polar", "Polar Coordinates", "極座標"), + ("ec.distort.reshape", "Reshape", "リシェープ"), + ("ec.distort.ripple", "Ripple", "波紋"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "ローリングシャッターの修復"), + ("ec.distort.smear", "Smear", "にじみ"), + ("ec.distort.spherize", "Spherize", "球面"), + ("ec.distort.transform", "Transform", "トランスフォーム"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "タービュレントディスプレイス"), + ("ec.distort.twirl", "Twirl", "回転"), + ("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(ディテールを保持)"), + ("ec.distort.warp", "Warp", "ワープ"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"), + ("ec.distort.wavewarp", "Wave Warp", "波形ワープ"), + ("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"), + ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), + ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), + ("ec.generate.beam", "Beam", "レーザー"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "セルパターン"), + ("ec.generate.checkerboard", "Checkerboard", "チェッカーボード"), + ("ec.generate.circle", "Circle", "円"), + ("ec.generate.ellipse", "Ellipse", "楕円"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "スポイト塗り"), + ("ec.generate.fill", "Fill", "塗り"), + ("ec.generate.fourcolor", "4-Color Gradient", "4色グラデーション"), + ("ec.generate.fractal", "Fractal", "フラクタル"), + ("ec.generate.gradientramp", "Gradient Ramp", "グラデーション"), + ("ec.generate.grid", "Grid", "グリッド"), + ("ec.generate.lensflare", "Lens Flare", "レンズフレア"), + ("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"), + ("ec.generate.radiowaves", "Radio Waves", "電波"), + ("ec.generate.scribble", "Scribble", "落書き"), + ("ec.generate.stroke", "Stroke", "線"), + ("ec.generate.vegas", "Vegas", "ベガス"), + ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), ("ec.key.colorkey", "Color Key", "カラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), @@ -460,6 +568,31 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ブラシストローク"), + ("ec.stylize.cartoon", "Cartoon", "カートゥーン"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "カラーエンボス"), + ("ec.stylize.emboss", "Emboss", "エンボス"), + ("ec.stylize.findedges", "Find Edges", "輪郭検出"), + ("ec.stylize.glow", "Glow", "グロー"), + ("ec.stylize.mosaic", "Mosaic", "モザイク"), + ("ec.stylize.motiontile", "Motion Tile", "モーションタイル"), + ("ec.stylize.posterize", "Posterize", "ポスタリゼーション"), + ("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"), + ("ec.stylize.scatter", "Scatter", "拡散"), + ("ec.stylize.strobe", "Strobe Light", "ストロボ"), + ("ec.stylize.texturize", "Texturize", "テクスチャ"), + ("ec.stylize.threshold", "Threshold", "しきい値"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), ]; From 6686cc75ca733931bc513d6711e6534b4b24d18c Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:28:38 +0300 Subject: [PATCH 0307/1386] fix(expr): bound runtime parser and native recursion depth (#2124, #2125) eval, Function and the generator/async function constructors now check the nesting of their source like the expression text. join, toLocaleString, flat and JSON.stringify check the run's stack use per level, results nested past the detached levels become [object], and Proxy/Reflect are no longer available to expressions. Each of these overflowed the stack and aborted the app. --- crates/expr/src/prelude.js | 116 +++++++++++++++++++++++--- crates/expr/src/rewrite.rs | 6 ++ crates/expr/src/runtime.rs | 48 ++++++++++- crates/expr/src/tests.rs | 1 + crates/expr/src/tests/deep_nesting.rs | 104 +++++++++++++++++++++++ 5 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 crates/expr/src/tests/deep_nesting.rs diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 4da7bc63b..3a43a9d10 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -670,14 +670,17 @@ var __groupHandler = { }, }; -function __mkGroup(c, l, info) { - var f = function (k) { return f.__get(k); }; - delete f.name; - delete f.length; - Object.setPrototypeOf(f, __GroupProto); - f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; - return new Proxy(f, __groupHandler); -} +// `Proxy` is private to the object model (see "Stack safety" at the end). +var __mkGroup = (function (P) { + return function (c, l, info) { + var f = function (k) { return f.__get(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, __GroupProto); + f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; + return new P(f, __groupHandler); + }; +})(Proxy); // Properties ------------------------------------------------------------------------------------ @@ -852,9 +855,11 @@ var __propHandler = { }, }; -function __mkProp(c, l, path) { - return new Proxy(new Prop(c, l, path), __propHandler); -} +var __mkProp = (function (P) { + return function (c, l, path) { + return new P(new Prop(c, l, path), __propHandler); + }; +})(Proxy); // Text styles (the Source Text style API) ------------------------------------------------------ // A TextStyle is the source document (as JSON from the host), the character index it reads its @@ -1069,3 +1074,92 @@ function velocityAtTime(t) { return thisProperty.velocityAtTime(t); } function speedAtTime(t) { return thisProperty.speedAtTime(t); } function key(n) { return thisProperty.key(n); } function nearestKey(t) { return thisProperty.nearestKey(t); } + +// --------------------------------------------------------------------------------------------- +// Stack safety (#2124, #2125). A stack overflow aborts the whole app, so nothing a script does +// may nest native code without bound. The expression text is checked for nesting before it is +// compiled; source handed to `eval` and the function constructors at run time is checked by +// `__nest`. The natives that walk nested values recursively (`join`, which `toString` and string +// conversion use, `toLocaleString`, `JSON.stringify`, `flat`) call `__stack` once per level, +// which throws once the run has used its share of the stack. `Proxy` (chains of proxies nest +// without any script frame) and `Reflect` are not available to expressions. +(function () { + var nest = __nest, stack = __stack, apply = Reflect.apply, construct = Reflect.construct; + var define = Object.defineProperty, describe = Object.getOwnPropertyDescriptor, proto = Object.getPrototypeOf; + // Replace `obj[name]` keeping its attributes. + function replace(obj, name, f) { + var d = describe(obj, name); + define(obj, name, { value: f, writable: d.writable, enumerable: d.enumerable, configurable: true }); + } + function guard(obj, name) { + var f = obj[name]; + replace(obj, name, function () { stack(); return apply(f, this, arguments); }); + } + + var evalJs = eval; + eval = function (x) { + if (typeof x === 'string') { + stack(); + nest(x); + } + return evalJs(x); + }; + // `Function` and the generator / async variants (reachable as `(function* () {}).constructor`). + function dynamic(F) { + var w = function () { + var args = []; + for (var i = 0; i < arguments.length; i++) args.push(String(arguments[i])); + stack(); + nest(args.slice(0, -1).join(',')); + nest(args.length ? args[args.length - 1] : ''); + return construct(F, args); + }; + define(w, 'name', { value: F.name, configurable: true }); + w.prototype = F.prototype; + replace(F.prototype, 'constructor', w); + return w; + } + Function = dynamic(Function); + dynamic(proto(function* () {}).constructor); + dynamic(proto(async function () {}).constructor); + dynamic(proto(async function* () {}).constructor); + + guard(Array.prototype, 'join'); + guard(Array.prototype, 'toLocaleString'); + var flat = Array.prototype.flat; + replace(Array.prototype, 'flat', function (depth) { + stack(); + var d = depth === undefined ? 1 : Number(depth); + return apply(flat, this, [d > 64 ? 64 : d]); + }); + + // A replacer function sees every value before it is serialized: the per-level check. An array + // replacer (a list of keys) becomes one that copies each object's listed keys, in list order. + var stringify = JSON.stringify; + replace(JSON, 'stringify', function (value, replacer, space) { + var fn = typeof replacer === 'function' ? replacer : null; + var keys = null; + if (Array.isArray(replacer)) { + keys = []; + for (var i = 0; i < replacer.length; i++) { + var k = replacer[i]; + if (typeof k === 'number' || k instanceof Number || k instanceof String) k = String(k); + if (typeof k === 'string' && keys.indexOf(k) < 0) keys.push(k); + } + } + return apply(stringify, JSON, [value, function (key, v) { + stack(); + if (fn !== null) v = apply(fn, this, [key, v]); + if (keys !== null && v !== null && typeof v === 'object' && !Array.isArray(v) && + !(v instanceof Number || v instanceof String || v instanceof Boolean)) { + var o = {}; + for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + v = o; + } + return v; + }, space]); + }); + + delete globalThis.Proxy; + delete globalThis.Reflect; +})(); diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs index 55e9800aa..f84b96bc4 100644 --- a/crates/expr/src/rewrite.rs +++ b/crates/expr/src/rewrite.rs @@ -758,6 +758,12 @@ pub fn check_nesting(src: &str) -> Result<(), String> { Ok(()) } +/// An error when plain JavaScript `src` (not rewritten: what `eval` and `Function` get at run +/// time) nests deeper than [`MAX_NESTING`] levels. +pub fn check_plain_nesting(src: &str) -> Result<(), String> { + if too_nested(&tokenize(src).0) { Err(too_deep()) } else { Ok(()) } +} + fn too_deep() -> String { format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)") } diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 53c2960ba..8afa839e5 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -51,6 +51,8 @@ thread_local! { pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; /// Script runs since the outermost evaluation on this thread began (nested ones included). pub static RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Stack address where the current script run began (0: none; see [`native_stack`]). + static STACK_BASE: std::cell::Cell = const { std::cell::Cell::new(0) }; } #[cfg(not(target_arch = "wasm32"))] @@ -232,17 +234,49 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + +/// `__stack()`: throw once the current run has used [`NATIVE_STACK`] (values nested so deeply +/// would overflow the stack, which aborts the app). +fn native_stack(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + let base = STACK_BASE.with(|b| b.get()); + if base != 0 && base.abs_diff(stack_here()) > NATIVE_STACK { + return Err(JsNativeError::range().with_message("value is nested too deeply").into()); + } + Ok(JsValue::undefined()) +} + +/// `__nest(src)`: throw when source handed to `eval` / `Function` at run time nests deeper than +/// the parser's stack allows (the expression text itself is checked before it is compiled). +fn native_nesting(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let src = string(args, 0, ctx)?; + crate::rewrite::check_plain_nesting(&src).map_err(|e| JsNativeError::range().with_message(e.trim_start_matches("Error: ").to_string()))?; + Ok(JsValue::undefined()) +} + impl Runtime { fn new() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(LOOP_LIMIT); limits.set_recursion_limit(400); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ + (js_string!("__stack"), 0, native_stack), (js_string!("__h"), 6, native_host), (js_string!("__wig"), 8, native_wiggle), (js_string!("__noise"), 3, native_noise), (js_string!("__rnd"), 6, native_random), + (js_string!("__nest"), 1, native_nesting), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; @@ -361,6 +395,9 @@ pub fn out_of_time() -> bool { } } +/// Levels of nested arrays a script result keeps. +const MAX_DETACH: usize = 4; + /// Copy a JS value out of the heap. fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { if v.is_undefined() { @@ -380,7 +417,7 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { } let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX); if let Some(o) = v.as_object() { - if o.is_array() && depth < 4 { + if o.is_array() && depth < MAX_DETACH { let arr = JsArray::from_object(o).map_err(err)?; let n = arr.length(ctx).map_err(err)?.min(1 << 16); let mut out = Vec::with_capacity(n as usize); @@ -408,6 +445,11 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { return Ok(Out::Path { points, ins, outs, closed }); } } + // Below the levels detached above, an object's text could be its whole nested content + // (`String([[[…]]])`), converted by natives that recurse once per level. + if depth >= MAX_DETACH && v.is_object() { + return Ok(Out::Object("[object]".into())); + } Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default())) } @@ -484,6 +526,8 @@ fn position_line(msg: &str) -> Option { /// `__begin` with memoized answers, so nothing carries over. A script that really overflows the /// stack fails again on the fresh context and reports the error. pub fn run(text: &str, b: &Begin) -> Run { + // Runs don't nest (other expressions are evaluated between runs), so one base suffices. + STACK_BASE.with(|s| s.set(stack_here())); RUNTIME.with(|r| { let mut r = r.borrow_mut(); for fresh in [false, true] { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..cf13f02f8 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1,5 +1,6 @@ use std::time::Instant; +mod deep_nesting; mod fuzz; use effectcraft_color::Label; diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs new file mode 100644 index 000000000..d544d6f80 --- /dev/null +++ b/crates/expr/src/tests/deep_nesting.rs @@ -0,0 +1,104 @@ +//! Deep nesting the expression text check can't see (#2124, #2125): source handed to `eval` / +//! `Function` at run time, and deeply nested values that boa's natives (`join`, `JSON.stringify`, +//! proxies…) walk recursively. Each used to overflow the stack, which aborts the whole app +//! (`catch_unwind` can't catch it); now each is an expression error. + +use super::*; + +/// The render threads' stack size (rayon's default): what the app gets. +const RENDER_STACK: usize = 2 << 20; + +/// Evaluate `text` on the title's source text (any result converts) on a render-sized stack. +fn eval_small_stack(text: &str) -> Result { + let text = text.to_string(); + std::thread::Builder::new() + .stack_size(RENDER_STACK) + .spawn(move || { + let f = fx(); + f.ev(f.title, "text/sourceText", &text, 0.0) + }) + .unwrap() + .join() + .unwrap() +} + +fn text_of(r: Result) -> String { + match r { + Ok(Value::Text(d)) => d.text, + other => panic!("not text: {other:?}"), + } +} + +/// A 100 000-deep array: `var a = []; … a = [a]` in a loop (under the loop limit). +const DEEP_ARRAY: &str = "var a = []; for (var i = 0; i < 100000; i++) a = [a];"; + +#[test] +fn eval_and_function_constructors_check_nesting() { + let deep = r#""(".repeat(1000) + "1" + ")".repeat(1000)"#; + for call in [ + format!("eval({deep})"), + format!("Function({deep})()"), + format!("new Function('a', {deep})(1)"), + format!("Function('a = ' + {deep}, 'return a')()"), + "Function.apply(null, '('.repeat(1000).split('').concat(['1']))".to_string(), + format!("(function () {{}}).constructor({deep})()"), + format!("Object.getPrototypeOf(function* () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function () {{}}).constructor({deep})"), + format!("Object.getPrototypeOf(async function* () {{}}).constructor({deep})"), + format!("Reflect.construct(Function, [{deep}])"), + ] { + let e = eval_small_stack(&call).expect_err(&call); + assert!(!e.is_empty(), "{call}"); + } +} + +#[test] +fn eval_and_function_still_work() { + let f = fx(); + assert_eq!(f.num("eval('1 + 2')", 0.0), 3.0); + assert_eq!(f.num("eval('[1, [2, (3)]]')[1][1]", 0.0), 3.0); + assert_eq!(f.num("new Function('a', 'b', 'return a * b')(3, 4)", 0.0), 12.0); + assert_eq!(f.num("Function('return 5')()", 0.0), 5.0); + assert_eq!(f.num("(function () {}).constructor('return 6')()", 0.0), 6.0); + assert_eq!(f.num("(function () {}) instanceof Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("(function () {}).constructor === Function ? 1 : 0", 0.0), 1.0); + assert_eq!(f.num("Object.getPrototypeOf(function* () { yield 7; }).constructor('yield 7')().next().value", 0.0), 7.0); +} + +#[test] +fn deep_array_result_is_an_error_or_text_not_a_crash() { + // The result itself: detaching it used to `toString` it past depth 4. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} a")); + // Explicit conversions inside the script. + for tail in ["a.join()", "a.toString()", "String(a)", "a + ''", "`${a}`", "a.toLocaleString()", "JSON.stringify(a)", "JSON.stringify({ a: a })"] { + let text = format!("{DEEP_ARRAY} {tail}"); + assert!(eval_small_stack(&text).is_err(), "{text}"); + } + // A deep array as a text style value goes through JSON.stringify in the prelude. + let _ = eval_small_stack(&format!("{DEEP_ARRAY} text.sourceText.style.setFontSize(a)")); +} + +#[test] +fn deep_proxy_chain_is_an_error_not_a_crash() { + let text = "var p = {}; for (var i = 0; i < 100000; i++) p = new Proxy(p, {}); p.x"; + assert!(eval_small_stack(text).is_err(), "{text}"); + // Neither is available to expressions; the object model's own proxies still work. + let f = fx(); + assert_eq!(f.text("typeof Proxy + ' ' + typeof Reflect", 0.0), "undefined undefined"); + assert_eq!(f.num("thisComp.layer('A').effect('Slider Control')('Slider')", 0.0), 42.0); +} + +#[test] +fn nested_values_still_convert() { + let f = fx(); + assert_eq!(f.text("[[1, 2], [3, [4, 5]]].join(';')", 0.0), "1,2;3,4,5"); + assert_eq!(f.text("String([[1, 2], 3])", 0.0), "1,2,3"); + assert_eq!(f.text("JSON.stringify({ a: [1, { b: [2] }] })", 0.0), r#"{"a":[1,{"b":[2]}]}"#); + assert_eq!(f.text("JSON.stringify({ a: 1, b: 2 }, ['b'])", 0.0), r#"{"b":2}"#); + assert_eq!(f.text("JSON.stringify([1, [2]], null, 1)", 0.0), "[\n 1,\n [\n 2\n ]\n]"); + assert_eq!(f.text("JSON.stringify({ a: 1 }, function (k, v) { return k === 'a' ? v + 1 : v; })", 0.0), r#"{"a":2}"#); + // Nested deeper than the detached levels: still converts to its text. + assert_eq!(text_of(eval_small_stack("[[[[[[1, 2]]]]]]")), ""); + assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1"); + assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0); +} From ee1358681404568b6890ca642deb07ef9921648a Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:13:50 +0300 Subject: [PATCH 0308/1386] svg: share CSS declaration blocks and cap {rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn matching_work_past_the_budget_errors_without_doing_it() { + // Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations. + let sels = vec!["rect"; 10_000].join(","); + let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("