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 001/685] 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 002/685] 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 003/685] 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 004/685] 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 005/685] 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 006/685] 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 677922e2e2cfbf11940b44f673b6272491fe06aa Mon Sep 17 00:00:00 2001 From: untypequicode Date: Mon, 12 Oct 2026 19:13:04 +0200 Subject: [PATCH 007/685] 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 008/685] 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 009/685] 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 baf62b22dfb0772453d3606c04fc40f609b097e0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:38:20 -0400 Subject: [PATCH 010/685] 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 011/685] 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 012/685] 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 c75ea46c8647459f4c7e186be878904286523244 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 07:56:29 -0400 Subject: [PATCH 013/685] 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 014/685] 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 f51b4b7e1be5310fab102a8f4d507b33775fdadd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 10:52:40 -0400 Subject: [PATCH 015/685] 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 016/685] 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 017/685] 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 018/685] 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 019/685] 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 020/685] 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 021/685] 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 022/685] 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 023/685] 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 024/685] 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 025/685] 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 026/685] 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 027/685] 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 028/685] 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 029/685] 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 030/685] 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 031/685] 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 8408af323a7526a484cca3f238adbc3e50c09562 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 14:21:14 -0600 Subject: [PATCH 032/685] 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 033/685] 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 034/685] 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 035/685] 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 036/685] 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 037/685] 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 038/685] 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 039/685] 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 040/685] 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 041/685] 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 042/685] 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 f2cdad9edbc787929987470a20382d252ba7344d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:57:19 -0400 Subject: [PATCH 043/685] 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 044/685] 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 045/685] 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 046/685] 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 047/685] 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 048/685] 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 049/685] 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 050/685] 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 051/685] 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 052/685] 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 053/685] 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 054/685] 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 055/685] 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 a1a1a36a417d7a5ee6298780be0692f241f79eef Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:38:38 +0400 Subject: [PATCH 056/685] 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 057/685] 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 058/685] 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 059/685] 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 060/685] #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 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 061/685] 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 062/685] 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 063/685] 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 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 064/685] 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 065/685] 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 1f81c813e1c2f427c2567a305af99cfa8ade8a31 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Fri, 16 Oct 2026 18:45:53 +0530 Subject: [PATCH 066/685] #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 067/685] 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 068/685] 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 069/685] 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 6c3a08e7802c7a71b496aaf75d8e9f23f34f0bf2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Sat, 17 Oct 2026 02:38:31 +0530 Subject: [PATCH 070/685] 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 071/685] 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 aff4b082d7157e426c81c6844df739d9b9f09cbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:23:58 -0700 Subject: [PATCH 072/685] 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 073/685] 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 074/685] #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 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 075/685] 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 076/685] 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 077/685] 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 078/685] 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 079/685] 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 080/685] 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 081/685] 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 6c6b54a182d5cbf3737981ec4fc117a5afe0c695 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 18 Oct 2026 19:54:25 +0400 Subject: [PATCH 082/685] 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 083/685] 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 084/685] 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 085/685] 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 086/685] 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 087/685] 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 3b9d243ac516c790aa4e7905b6e0cd003426b157 Mon Sep 17 00:00:00 2001 From: Anubhav-Rai Date: Sun, 18 Oct 2026 22:46:28 +0530 Subject: [PATCH 088/685] =?UTF-8?q?Shift=20Channels:=20After=20Effects'=20?= =?UTF-8?q?Take=20=E2=80=A6=20From=20menu=20order,=20so=2010=20is=20Full?= =?UTF-8?q?=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 089/685] 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 090/685] 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 091/685] 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 092/685] 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 093/685] 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 094/685] 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 095/685] 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 7999754a139dfdcfb575dab9e9a92d0a09960638 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 03:20:50 +0400 Subject: [PATCH 096/685] 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 097/685] 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 098/685] 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 099/685] 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 100/685] 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 101/685] 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 102/685] 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 103/685] =?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 104/685] 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 105/685] 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 106/685] 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 107/685] 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 108/685] 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 109/685] 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 110/685] 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 111/685] 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 112/685] 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 113/685] 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 114/685] 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 115/685] 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 116/685] 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 117/685] 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 118/685] 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 119/685] 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 120/685] 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 121/685] 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 122/685] 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 123/685] 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 124/685] =?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 125/685] 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 126/685] 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 127/685] 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 128/685] 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 129/685] 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 130/685] 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 131/685] 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 132/685] 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 133/685] 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 134/685] 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 135/685] 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 136/685] 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 137/685] 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 138/685] 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 139/685] 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 140/685] 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 141/685] 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 142/685] 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 143/685] 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 144/685] 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 145/685] 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 146/685] 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 147/685] 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 148/685] 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 149/685] 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 daedbd5ca1f0e727a54a16f7df2889067b4aebed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:21 +0200 Subject: [PATCH 150/685] 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 151/685] 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 c628dc7d96982955e3cf437cf9b2715d4d4f0c93 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 21:42:50 +0400 Subject: [PATCH 152/685] 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 153/685] 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 154/685] 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 155/685] 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 156/685] 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 157/685] 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 158/685] 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 159/685] 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 160/685] 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 161/685] 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 162/685] 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 163/685] 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 164/685] 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 57326dc6a7d2fcd187cc45edba2dac4433894ac1 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 01:51:23 +0300 Subject: [PATCH 165/685] 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 166/685] 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 167/685] 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 168/685] 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 169/685] 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 170/685] 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 171/685] 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 de3fec28ed7a3d7a700a9d8ea307779dcda99a8e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:18:54 -0400 Subject: [PATCH 172/685] 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 173/685] 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 174/685] 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 175/685] 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 176/685] =?UTF-8?q?G5:=20the=20playing=20prefetch=20window?= =?UTF-8?q?=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 177/685] 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 178/685] 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 179/685] 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 180/685] 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 181/685] 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 182/685] 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 183/685] 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 184/685] 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 185/685] 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 186/685] 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 187/685] 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 188/685] 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 189/685] 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 190/685] 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 191/685] 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 192/685] 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 193/685] 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 194/685] 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 195/685] 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 196/685] 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 197/685] 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 198/685] 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 199/685] 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 200/685] 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 201/685] 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 202/685] 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 203/685] 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 204/685] 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 205/685] 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 206/685] 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 207/685] 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 208/685] 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 209/685] 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 210/685] 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 211/685] 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 212/685] 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 213/685] 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 214/685] 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 215/685] 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 216/685] 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 ca88af2b5f271c5c8be324cbe0574e99ece07bf2 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:34:45 +0530 Subject: [PATCH 217/685] 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 218/685] 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 219/685] 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 220/685] 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 221/685] 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 222/685] 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 223/685] 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 224/685] 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 225/685] 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 226/685] 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 227/685] 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 228/685] 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 229/685] 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 230/685] 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 714b6c29b02716dbd64ff02bd132998fa9e2600f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:46:55 -0400 Subject: [PATCH 231/685] 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 232/685] 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 233/685] 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 234/685] 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 235/685] 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 236/685] 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 237/685] 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 238/685] 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 239/685] 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 240/685] 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 241/685] 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 242/685] 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 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 243/685] 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 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 244/685] 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 966e27969107278054b912bd35f31274d944ec1c Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:10:51 +0300 Subject: [PATCH 245/685] 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 246/685] 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 247/685] 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 248/685] 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 249/685] 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("