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/546] 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/546] 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/546] 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/546] 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/546] 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/546] docs: drop the docs workflows until the checker is on artcraft-services main Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-check.yml | 33 ------------------------------ .github/workflows/docs-publish.yml | 22 -------------------- docs/user/AGENTS.md | 6 ++---- 3 files changed, 2 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/docs-check.yml delete mode 100644 .github/workflows/docs-publish.yml diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml deleted file mode 100644 index 079b9d414..000000000 --- a/.github/workflows/docs-check.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: docs-check - -on: - pull_request: - paths: ["docs/user/**"] - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: - contents: read - -env: - # Shared docs checker from storytold/artcraft-services, pinned to one commit - # and its SHA-256. To update it, change both values in a reviewed PR. - CHECKER_COMMIT: 4db0e30fe2c57a1c4c49e390e3711f90910b8d23 - CHECKER_SHA256: bd6b610a708b85fd0aa0491c459bd2800b716e0b8050516fea79e084cfb2a624 - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Download the pinned docs checker - run: | - url="https://raw.githubusercontent.com/storytold/artcraft-services/$CHECKER_COMMIT/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" - curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs" - echo "$CHECKER_SHA256 $RUNNER_TEMP/docs-core.mjs" | sha256sum -c - - - name: Check docs/user - run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml deleted file mode 100644 index df4a412cc..000000000 --- a/.github/workflows/docs-publish.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: docs-publish - -on: - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: {} - -jobs: - rebuild: - runs-on: ubuntu-latest - steps: - - name: Rebuild getartcraft.com - env: - HOOK: ${{ secrets.DOCS_BUILD_HOOK }} - run: | - if [ -z "$HOOK" ]; then - echo "DOCS_BUILD_HOOK is not set; the nightly website build will pick this up." - exit 0 - fi - curl -fsS -X POST -d '{}' "$HOOK?trigger_title=docs%20update%20from%20$GITHUB_REPOSITORY" diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index 0985231b1..2c3add101 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -53,11 +53,9 @@ drafts and are not published. ## Check before you push - commit=$(awk '/CHECKER_COMMIT:/ {print $2}' .github/workflows/docs-check.yml) - curl -fsSL "https://raw.githubusercontent.com/storytold/artcraft-services/$commit/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" -o /tmp/docs-core.mjs - node /tmp/docs-core.mjs docs/user + node /frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user -This uses the same pinned checker as CI (`.github/workflows/docs-check.yml`). +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. ## Support PRs From 47051fcb82bbd89e316c4ddf2347a59cfdea3d29 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:32:28 +0200 Subject: [PATCH 007/546] #1010: keep a parented camera's or light's Point of Interest in place Parenting a camera or light (layer.setParent, compensate on) now re-expresses its Position and Point of Interest in the new parent's space, like the other 3D layers. The 3D loop in parent_fixes skipped cameras and lights, so their positions stayed in the old space and the camera moved by the null's offset. Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 14 +++++++++++--- crates/engine/src/tests_rig3d.rs | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..5396df6b7 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -591,7 +591,8 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, /// A uniform relative parent transform can preserve static Orientation and Scale. pose_3d: Option<([f64; 3], f64)>, @@ -626,7 +627,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -646,6 +648,12 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); + } + if let Some(pr) = tr.get_mut("poi") { + map_point(pr); } if let Some((orientation, factor)) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { @@ -793,7 +801,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) { + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d3b965107..2027f4a05 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -207,6 +207,29 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From bb2153f112ead1c1c10e8c6246d70b663d8adcdf Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:37:05 +0200 Subject: [PATCH 008/546] #1010: recover a static Orientation's Y angle without losing precision near gimbal lock uniform_parent_pose took the Y angle from asin, which loses precision within about 1e-6 degrees of +-90. The Y angle now comes from atan2 on cos(Y), as the X and Z angles already do. The gimbal branch and the principal-range output are unchanged. A new test parents a static Orientation with Y 1e-6 degrees off gimbal lock under a uniform parent. Its largest corner displacement was 3.3e-8 px before this change and 1.5e-14 px after. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 7 +++--- crates/engine/tests/parenting_3d_position.rs | 23 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5396df6b7..c225612c3 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -751,9 +751,10 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti return Some((orientation, factor)); } let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; - let sy = (-r[2][0]).clamp(-1.0, 1.0); - let y = sy.asin(); - let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. + let cy = r[0][0].hypot(r[1][0]); + let y = (-r[2][0]).atan2(cy); + let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8ad..6bf0c4ff1 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -383,6 +383,29 @@ fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); } +/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose +/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there. +#[test] +fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { + let mut max_corners = 0.0f64; + for factor in [120.0, -120.0] { + for y in [90.0 - 1e-6, -90.0 + 1e-6] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); + set(&mut s, p, "transform/rotationX", json!(0.0)); + set(&mut s, p, "transform/rotationY", json!(0.0)); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0])); + set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); + let before = world(&s, c, Tick::ZERO); + parent(&mut s, c, Some(p), true); + max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO))); + } + } + assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}"); +} + #[test] fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { From 114c4797639eaf657e879dada91738b8cd72e76b Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:40:39 +0200 Subject: [PATCH 009/546] #1010: keep a rotated null's effect on a camera's or light's static Orientation Parenting a camera or light to a rotated uniform null now re-expresses its static Orientation, so a one-node light or camera keeps its direction. A two-node camera or light also keeps its roll: its Orientation is re-expressed against its new aim (aim_correction), so Up does not drift. Keyed Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 116 +++++++++++++++++++++------- crates/engine/src/tests_rig3d.rs | 54 ++++++++++++- 2 files changed, 142 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index c225612c3..0e76b9d0c 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -594,8 +594,17 @@ pub(crate) struct ParentFix { /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -605,7 +614,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -655,11 +664,12 @@ impl ParentFix { if let Some(pr) = tr.get_mut("poi") { map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -722,13 +732,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -747,15 +754,47 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. +fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + use effectcraft_geom::{Mat4, Vec3}; + let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the +/// authored values unchanged. +fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { + return orientation; + } + euler_of(correction, orientation) +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is @@ -807,23 +846,46 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); // Animated Euler Orientation and auto-orient cannot be re-expressed by // prepending a constant Euler value. Expression-driven values stay authored. + // Auto-orient replaces Orientation, except a two-node rig, whose roll is + // re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); if !orientation.keys.is_empty() || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 2027f4a05..6a13d8679 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -230,6 +230,58 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { assert!((f2 - f0).length() < 1e-6, "{f2:?}"); } +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From 707161bc631d68db5da6181c6e78b8ce4250dae7 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:43:16 +0200 Subject: [PATCH 010/546] #1010: re-express keyed Orientation per key under a rotated uniform parent Parenting a 3D layer under a rotated uniform parent now re-expresses keyed Orientation at every key, not only the static value. A Z-only correction shifts Z exactly, so the paths between keys stay exact. Other corrections keep each key's authored turns, so the Euler values stay continuous. This changes keyed 3D layers under any rotated uniform parent, not only cameras and lights. The boundary test now sets the non-uniform parent before the animated and static cases, so both keep their authored pose. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 83 +++++++++++++++----- crates/engine/src/tests_rig3d.rs | 18 +++++ crates/engine/tests/parenting_3d_position.rs | 25 +++++- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 0e76b9d0c..8222ad34e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -666,7 +666,7 @@ impl ParentFix { } if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { let factor = pose.factor; @@ -757,10 +757,15 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4 Some((rotation, factor)) } -/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. -fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - use effectcraft_geom::{Mat4, Vec3}; - let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); @@ -768,13 +773,55 @@ fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; [x.to_degrees(), y.to_degrees(), z.to_degrees()] } -/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the -/// authored values unchanged. -fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return orientation; +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only +/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other +/// corrections keep each key's authored turns, so the Euler values stay continuous. +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &mut pr.keys { + let o = key.value.as_vec3(); + // Each key keeps the authored change from the previous key on top of its converted value. + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + key.value = KV::Vec3(n); + prev = Some((o, n)); } - euler_of(correction, orientation) } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -848,13 +895,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op let orientation = tr.get("orientation")?; // Cameras and lights have no Scale; their Orientation is re-expressed alone. let scale = tr.get("scale"); - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - // Auto-orient replaces Orientation, except a two-node rig, whose roll is - // re-expressed against its new aim. + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); - if !orientation.keys.is_empty() - || orientation.expr.is_some() + if orientation.expr.is_some() || scale.is_some_and(|scale| scale.expr.is_some()) || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { @@ -873,7 +917,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op } else { rotation }; - if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } if let Some(scale) = scale { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 6a13d8679..87bb98699 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,6 +282,24 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } +/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its +/// direction at every key. +#[test] +fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; + let before = keys.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in keys.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); + } +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 6bf0c4ff1..3737d16e2 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { From 677922e2e2cfbf11940b44f673b6272491fe06aa Mon Sep 17 00:00:00 2001 From: untypequicode Date: Mon, 12 Oct 2026 19:13:04 +0200 Subject: [PATCH 011/546] 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 012/546] 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 013/546] Markers: the disk cache key ignores them too unless an expression reads them (#1083) Project::reads_markers and Comp::pixel_form_with are shared by the RAM and disk frame caches. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests_disk_cache.rs | 20 ++++++++++++++++ crates/project/src/lib.rs | 33 ++++++++++++++++++++++++++- crates/render/src/disk_cache.rs | 7 ++++-- crates/ui-egui/src/frames.rs | 14 +----------- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs index 3091964ad..9c4daa954 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests_disk_cache.rs @@ -136,3 +136,23 @@ fn footage_changes_change_the_salt() { assert_ne!(k3, k2); assert_ne!(set_proxy(&mut s, false), k3); } + +/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to +/// render again, #1083); an expression that reads markers makes them count. +#[test] +fn markers_keep_the_content_key_unless_an_expression_reads_them() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid); + let k0 = key(&s); + s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap(); + s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap(); + assert_eq!(key(&s), k0, "markers"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap(); + let k1 = key(&s); + assert_ne!(k1, k0); + s.execute("comp.addMarker", json!({"time": 1.0})).unwrap(); + assert_ne!(key(&s), k1, "a marker read by an expression"); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index d7f34b5b8..57824de8c 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -881,14 +881,30 @@ impl Comp { /// The comp with those switches and the Resolution at their defaults (borrowed when they /// already are), for keys that hash the whole comp. pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> { - if !self.hide_shy && self.resolution.is_auto() && self.layers.iter().all(|l| l.switches == l.switches.pixels()) { + self.pixel_form_with(true) + } + + /// [`Self::pixel_form`], also without the comp's and its layers' markers when not + /// `with_markers` (see [`Self::same_pixels_with`] and [`Project::reads_markers`]). + pub fn pixel_form_with(&self, with_markers: bool) -> std::borrow::Cow<'_, Comp> { + if !self.hide_shy + && self.resolution.is_auto() + && self.layers.iter().all(|l| l.switches == l.switches.pixels()) + && (with_markers || (self.markers.is_empty() && self.layers.iter().all(|l| l.markers.is_empty()))) + { return std::borrow::Cow::Borrowed(self); } let mut c = self.clone(); c.hide_shy = false; c.resolution = Resolution::Auto; + if !with_markers { + c.markers.clear(); + } for l in &mut c.layers { l.switches = l.switches.pixels(); + if !with_markers { + l.markers.clear(); + } } std::borrow::Cow::Owned(c) } @@ -1339,6 +1355,21 @@ impl Default for Project { } impl Project { + /// Some expression mentions markers (`marker.key(1)`, `numKeys`, `nearestKey`...). Only then + /// do the markers of comps and layers affect what is rendered, so frame caches ignore them + /// otherwise. + pub fn reads_markers(&self) -> bool { + let mut found = false; + for (_, c) in self.comps() { + for l in &c.layers { + if !found { + l.props.walk("", &mut |_, p| found |= p.expr.as_ref().is_some_and(|e| e.text.to_ascii_lowercase().contains("marker"))); + } + } + } + found + } + /// Allocate an id (items, layers and property uids share one counter). pub fn alloc(&mut self) -> u64 { let id = self.next_id; diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index da46708e7..9c3655f72 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -780,7 +780,8 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { /// Content key of a composition's pixels: project render settings, the comp and everything it /// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy /// files' size and time). With expressions in play (which can read any comp) the whole project -/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. +/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count, and +/// neither do markers unless an expression mentions them. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); @@ -805,13 +806,15 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf } } } + // Markers are pixels only to the expressions that read them (#1083). + let markers = project.reads_markers(); let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; for id in ids { let Some(it) = project.item(id) else { continue }; h.write_u64(id.0); h.write(it.name.as_bytes()); match &it.kind { - ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()), + ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form_with(markers)), k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 0923e5eb8..e41a06f7e 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -291,18 +291,6 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 { comp_content_with(project, comp, |_, c| Arc::as_ptr(c)) } -/// Some expression in the project mentions markers (`marker.key(1)`, `numKeys`, `nearestKey`…). -/// Only then do the markers of its comps and layers affect what is rendered. -fn reads_markers(project: &Project) -> bool { - fn group(g: &PropGroup) -> bool { - g.children.iter().any(|c| match c { - Node::Prop(p) => p.expr.as_ref().is_some_and(|e| e.text.to_ascii_lowercase().contains("marker")), - Node::Group(sub) => group(sub), - }) - } - project.comps().any(|(_, c)| c.layers.iter().any(|l| group(&l.props))) -} - /// [`comp_content`] with each comp's address given by `addr`. fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc) -> *const Comp) -> u64 { use std::hash::{Hash, Hasher}; @@ -716,7 +704,7 @@ impl Frames { let plain = comp_content(project, comp); let Ok(mut bases) = self.bases.lock() else { return (plain, None) }; // Markers are pixels only to the expressions that read them (#1083). - let markers = reads_markers(project); + let markers = project.reads_markers(); if let Some((base_id, base)) = bases.get(&comp.0) && *base_id != plain && comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) { From 08d024e8866f0b1751fb323772511d1daf1d0006 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:00:52 +0200 Subject: [PATCH 014/546] #1063: find a Levels alpha control on the effect by its name Pro Levels2 keeps its Alpha controls one group down, under Alpha, and a script's effect.property("Alpha Input White") only searched the effect's direct children, so it returned null. The lookup now searches the groups nested below the direct children, up to three levels, after the direct match fails. Direct children keep their priority. A regression test checks the name on the effect and the same control through the Alpha group. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 49 +++++++++++++++++++++++++------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..dfdf458c9 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -402,6 +402,38 @@ fn alias(k: &str) -> Option<&'static str> { }) } +/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha +/// controls sit one group down, under Alpha). +const NESTED_CHILD_DEPTH: u8 = 3; + +/// The child of `g` that a string key names. The direct children are searched first, then the +/// groups nested below them, up to `depth` levels. +fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> { + let chain_of = |n: &Node| -> String { + let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + .or_else(|| { + if depth == 0 { + return None; + } + g.children.iter().find_map(|n| match n { + Node::Group(sub) => find_child(l, sub, k, depth - 1), + Node::Prop(_) => None, + }) + }) +} + fn child(s: &Session, a: &J) -> R { let (_, c, l) = layer_of(s, a)?; let uid = u(a, "uid").unwrap_or(0); @@ -409,22 +441,7 @@ fn child(s: &Session, a: &J) -> R { let key = a.get("key").cloned().unwrap_or(J::Null); let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), - J::String(k) => { - let chain_of = |n: &Node| -> String { - let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; - ch.push(n); - matchnames::node(&ch) - }; - let lk = loose(k); - g.children - .iter() - .find(|n| chain_of(n) == *k) - .or_else(|| g.children.iter().find(|n| n.name() == k)) - .or_else(|| g.children.iter().find(|n| n.match_id() == k)) - .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) - .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) - .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) - } + J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), _ => None, }; match found { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..27812b825 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,21 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their +/// name on the effect as well as through the group (#1063). +#[test] +fn scripted_levels_alpha_controls_resolve_from_the_effect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30); + var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2"); + fx.property("Alpha Input White").setValue(0.5); + [fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value] + "#, + ); + assert_eq!(o.result, json!([0.5, 0.5])); +} From 55ce6017107557ab0c8cc2a26e070a70e9636648 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:48:16 +0200 Subject: [PATCH 015/546] #1063: return the text animator property a script asked for addProperty("ADBE Text Tracking Amount") returned the first property added to the animator, which is the Tracking Type companion, so the script changed the wrong value. The engine now reports the requested property's position in the Properties group, and the script returns that property. An animator's Properties group also finds its properties by their After Effects names, such as ADBE Text Tracking Amount, which the name table already had in the other direction. A regression test checks the property returned, its value, and the lookup by name. Not covered: the Advanced and Wiggly selector children and the Expression amount, which still keep their own names. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/text_anim.rs | 6 +++++- crates/script/src/model.rs | 28 ++++++++++++++++++------- crates/script/src/prelude.js | 5 ++--- crates/script/src/tests.rs | 20 ++++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d48424..fff32c2ff 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -139,6 +139,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -148,10 +149,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index dfdf458c9..9df540b86 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -415,11 +415,14 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option< matchnames::node(&ch) }; let lk = loose(k); + // An animator's Properties group also answers to the After Effects names of its properties. + let animator_props = g.match_id == "properties"; g.children .iter() .find(|n| chain_of(n) == k) .or_else(|| g.children.iter().find(|n| n.name() == k)) .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k))) .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) @@ -671,10 +674,9 @@ fn resolve_add(s: &Session, a: &J) -> R { }) } -/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match -/// name or a display name. -fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ +/// The After Effects match name and key of each text animator property. +fn animator_property_table() -> &'static [(&'static str, &'static str)] { + &[ ("ADBE Text Anchor Point 3D", "anchor"), ("ADBE Text Position 3D", "position"), ("ADBE Text Scale 3D", "scale"), @@ -698,12 +700,24 @@ fn text_animator_property(name: &str) -> Option<&'static str> { ("ADBE Text Character Offset", "characterOffset"), ("ADBE Text Character Replace", "characterValue"), ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + ] +} + +/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match +/// name or a display name. +fn text_animator_property(name: &str) -> Option<&'static str> { + let table = animator_property_table(); + if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) +} + +/// The After Effects match name of a text animator property key (`tracking` is +/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063). +fn animator_property_match_name(key: &str) -> Option<&'static str> { + animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..dc159b878 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -718,9 +718,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 27812b825..7170652bf 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1028,3 +1028,23 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() { ); assert_eq!(o.result, json!([0.5, 0.5])); } + +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"])); +} From 95f2f3ec5528fd2d51716cd48fd043480c4a245f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:52:08 +0200 Subject: [PATCH 016/546] #1010: leave keyed Orientation authored under a tilted parent Euler angles are blended between keys, so re-expressing only the key values cannot keep the motion between them under a tilted parent. Keyed Orientation is now re-expressed only for a Z-only correction, which shifts Z exactly at every time; static Orientation is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 23 ++++++++------------- crates/engine/src/tests_rig3d.rs | 32 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8222ad34e..7208c1ff1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,9 +789,10 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } -/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only -/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other -/// corrections keep each key's authored turns, so the Euler values stay continuous. +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key, so the paths between keys stay exact too. Any other correction is applied +/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are +/// blended between keys, which no re-expression of the keys can reproduce. fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -810,18 +811,6 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); - let mut prev: Option<([f64; 3], [f64; 3])> = None; - for key in &mut pr.keys { - let o = key.value.as_vec3(); - // Each key keeps the authored change from the previous key on top of its converted value. - let reference = match prev { - Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], - None => o, - }; - let n = reexpress_euler(correction, o, reference); - key.value = KV::Vec3(n); - prev = Some((o, n)); - } } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -923,6 +912,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } + // Keys can only follow a Z-only correction exactly; otherwise they stay authored. + if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { + return None; + } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 87bb98699..ecae51cb1 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,22 +282,38 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } -/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its -/// direction at every key. +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. #[test] -fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { let mut s = session(); let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); } - let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; - let before = keys.map(|t| frame_at(&s, light, t)); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so +/// keyed Orientation stays as authored rather than changing the motion between the keys. +#[test] +fn keyed_orientation_under_a_tilted_null_stays_authored() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - for (t, b) in keys.into_iter().zip(before) { - assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); - } + let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); + assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From e78f115d71ca5ab4ec9fc4e073836cb3cb445ccc Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:57:25 +0200 Subject: [PATCH 017/546] #1010: add keys so keyed Orientation follows a tilted parent between the keys Under a tilted parent, the converted keys alone give a different path between them, since Euler angles are blended. Keys are now added wherever the blend drifts more than 0.05 degrees from the exact rotation (halving each segment, at most 10 times and 4096 keys). The segments become straight blends with the original easing baked in; hold segments are kept. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 95 ++++++++++++++++++++++++++--- crates/engine/src/tests_rig3d.rs | 31 ++++++---- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 7208c1ff1..c112a4912 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,10 +789,48 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= BAKE_MAX_KEYS { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, out); + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, out); +} + /// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the -/// value and every key, so the paths between keys stay exact too. Any other correction is applied -/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are -/// blended between keys, which no re-expression of the keys can reproduce. +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -811,6 +849,53 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, &mut between); + // An eased segment, or one that gained keys, becomes a run of straight blends. + if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -912,10 +997,6 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } - // Keys can only follow a Z-only correction exactly; otherwise they stay authored. - if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { - return None; - } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index ecae51cb1..d867337ac 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -300,20 +300,27 @@ fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { } } -/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so -/// keyed Orientation stays as authored rather than changing the motion between the keys. +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. #[test] -fn keyed_orientation_under_a_tilted_null_stays_authored() { - let mut s = session(); - let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); - for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { - s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); } - let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); - s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From baf62b22dfb0772453d3606c04fc40f609b097e0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 21:38:20 -0400 Subject: [PATCH 018/546] 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 019/546] 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 020/546] 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 021/546] 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 022/546] docs: re-verify against 0.9.2 - verified: 0.9.2 on every page, checked against the v0.9.2 tag and release assets - install, price-and-platforms: Windows x86 builds, riscv64 deb/rpm, macOS CLI zip - gpu-not-used: GPU Information now names the adapter or why the GPU is off; OpenGL means CPU compositing; #613 still open - import-footage: TGA, HDR, DPX, Cineon sequences; dropped PSDs ask; drop on an empty viewer makes a comp - render-a-video: QuickTime (Uncompressed) format - make-a-composition: Layer > New > Text starts typing; save and delete presets - CLAUDE.md: regular file instead of a symlink Co-Authored-By: Claude Opus 5.5 --- docs/user/CLAUDE.md | 2 +- docs/user/faq/after-effects-projects.md | 2 +- docs/user/faq/price-and-platforms.md | 16 +++++----- docs/user/getting-started/install.md | 10 ++++-- docs/user/guides/add-an-effect.md | 2 +- docs/user/guides/animate-with-keyframes.md | 2 +- docs/user/guides/import-footage.md | 8 ++--- docs/user/guides/make-a-composition.md | 6 ++-- docs/user/guides/render-a-video.md | 33 ++++++++++---------- docs/user/index.md | 2 +- docs/user/troubleshooting/footage-missing.md | 2 +- docs/user/troubleshooting/gpu-not-used.md | 8 +++-- 12 files changed, 51 insertions(+), 42 deletions(-) mode change 120000 => 100644 docs/user/CLAUDE.md diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/docs/user/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/after-effects-projects.md b/docs/user/faq/after-effects-projects.md index 2c22e6644..792aa7cc1 100644 --- a/docs/user/faq/after-effects-projects.md +++ b/docs/user/faq/after-effects-projects.md @@ -2,7 +2,7 @@ title: Can EffectCraft open After Effects projects? description: EffectCraft cannot open .aep or .aepx files, and After Effects plug-ins do not run. It saves .ecproj files and imports Lottie. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- No. EffectCraft cannot open After Effects projects (`.aep` or `.aepx`), and After Effects plug-ins do not run in it. diff --git a/docs/user/faq/price-and-platforms.md b/docs/user/faq/price-and-platforms.md index 546d3a3d3..40f93c60d 100644 --- a/docs/user/faq/price-and-platforms.md +++ b/docs/user/faq/price-and-platforms.md @@ -2,20 +2,20 @@ title: Is EffectCraft free, and where does it run? description: EffectCraft is free and open source under MIT or Apache-2.0, and runs on Windows, macOS, Linux and in the browser. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Yes, EffectCraft is free. It is open source, dual-licensed under MIT or Apache-2.0, at your option. It has no telemetry, and it uses no network access unless you ask for it. ## Where it runs -| Platform | Builds | -|----------|------------------------------------------------------------------------------| -| Windows | x64 and arm64, as MSI installers and portable zips | -| macOS | One universal app for Apple silicon and Intel | -| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 tar.gz | -| FreeBSD | x86_64 tar.gz | -| Browser | A web build that runs the full app | +| Platform | Builds | +|----------|----------------------------------------------------------------------------------------| +| Windows | x64, arm64 and 32-bit x86, as MSI installers and portable zips | +| macOS | One universal app for Apple silicon and Intel | +| Linux | x86_64 and aarch64 as AppImage, Flatpak, deb, rpm and tar.gz; riscv64 deb, rpm, tar.gz | +| FreeBSD | x86_64 tar.gz | +| Browser | A web build that runs the full app | See [Install EffectCraft](../getting-started/install.md) for the files. diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 605fce2be..b2e77a5d9 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -2,10 +2,10 @@ title: Install EffectCraft description: Download EffectCraft for Windows 11, macOS or Linux, install it, or install it through ArtCraft Launcher. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- -The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.8.0`. +The easiest way is the [EffectCraft page on getartcraft.com](https://getartcraft.com/apps/effectcraft). For a specific file, use the [latest release](https://github.com/storytold/effectcraft/releases/latest) on GitHub. In the file names, `` stands for the version number, for example `0.9.2`. ## Windows 11 @@ -18,11 +18,15 @@ The release also has an `arm64` installer for Snapdragon and other ARM PCs. The To run it without installing, download `effectcraft--windows-x64-portable.zip` instead. It contains `effectcraft.exe` and the command-line tool `effectcraft-cli.exe`. The `arm64` build has a portable zip too. +There are also 32-bit `x86` builds, as an installer and a portable zip. + ## macOS 1. Download `effectcraft--macos-universal.dmg`. It works on Apple silicon and Intel Macs and is signed and notarized. 2. Open it and drag EffectCraft to Applications. +The command-line tool is a separate download for macOS: `effectcraft-cli--macos-universal.zip`. + ## Linux Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. @@ -35,7 +39,7 @@ Pick one file from the release. Replace `x86_64` with `aarch64` on ARM. | `effectcraft--linux-x86_64.flatpak` | `flatpak install --user ` | | `effectcraft--linux-x86_64.tar.gz` | Unpack it anywhere. | -There is also a RISC-V 64-bit `.tar.gz` (it needs glibc 2.39 or newer) and a FreeBSD x86_64 `.tar.gz`. +For RISC-V 64-bit there is a `.deb`, an `.rpm` and a `.tar.gz` (`riscv64` in the file name). They need glibc 2.39 or newer. There is also a FreeBSD x86_64 `.tar.gz`. ## In the browser diff --git a/docs/user/guides/add-an-effect.md b/docs/user/guides/add-an-effect.md index f6e4d53ba..5459b21d9 100644 --- a/docs/user/guides/add-an-effect.md +++ b/docs/user/guides/add-an-effect.md @@ -2,7 +2,7 @@ title: Add an effect description: Apply an effect such as Gaussian Blur to a layer and change its settings in Effect Controls. order: 30 -verified: 0.8.0 +verified: 0.9.2 --- Select a layer, then choose the effect from the **Effect** menu, or double-click it in the **Effects & Presets** panel. Change its settings in **Effect Controls** (F3). diff --git a/docs/user/guides/animate-with-keyframes.md b/docs/user/guides/animate-with-keyframes.md index ceaa526f5..53ef7882e 100644 --- a/docs/user/guides/animate-with-keyframes.md +++ b/docs/user/guides/animate-with-keyframes.md @@ -2,7 +2,7 @@ title: Animate with keyframes description: Fly a layer in with two Position keyframes and smooth the motion with Easy Ease. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Click the stopwatch beside a property to start animating it, move the current-time indicator, then change the value. The second keyframe appears by itself. Select both keyframes and press F9 to ease the motion. diff --git a/docs/user/guides/import-footage.md b/docs/user/guides/import-footage.md index 5792e961b..2cd754bd2 100644 --- a/docs/user/guides/import-footage.md +++ b/docs/user/guides/import-footage.md @@ -2,7 +2,7 @@ title: Import footage and image sequences description: Bring video, images and numbered image sequences into an EffectCraft project, and change how footage is read. order: 50 -verified: 0.8.0 +verified: 0.9.2 --- Choose **File ▸ Import ▸ File...** (Ctrl+I) and pick your files, or drop files on the EffectCraft window. Numbered stills such as `shot_0001.png` can import as one sequence that plays like a movie. @@ -13,9 +13,9 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl and Option in 1. Choose **File ▸ Import ▸ File...** to pick one file. Use **File ▸ Import ▸ Multiple Files...** to pick several. 2. Photoshop and PDF or Illustrator files first ask whether to import them as footage or as a composition. -3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. +3. The footage appears in the Project panel. Drag it onto the Composition viewer to make a layer where you drop it, or into the Timeline. If no composition is open, dropping it on the empty viewer makes a new composition from it. -You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. +You can also drop files or folders on the window. Files dropped on the Composition viewer become layers there. A dropped Photoshop file asks how to import it, as **File ▸ Import ▸ File...** does. The **Media Browser** (**Window ▸ Media Browser**) browses folders and imports what you pick. ## Import an image sequence @@ -27,7 +27,7 @@ You can also drop files or folders on the window. Files dropped on the Compositi **Force alphabetical order** imports every image of that type in the folder in name order, whatever the numbering. Use it for files that are not numbered consistently. -Stills in PNG, JPEG, TIFF, OpenEXR, BMP and WebP can form a sequence. PSD, SVG and PDF files import one by one. +Stills in PNG, JPEG, TIFF, OpenEXR, BMP, WebP, Targa (`.tga`), Radiance HDR (`.hdr`), DPX and Cineon (`.cin`) can form a sequence. PSD, SVG and PDF files import one by one. If the numbering skips a frame, EffectCraft shows colour bars for it. Put the missing files in the folder and choose **File ▸ Reload Footage** to fill the gap. diff --git a/docs/user/guides/make-a-composition.md b/docs/user/guides/make-a-composition.md index 9b32b3d41..5dcd8702b 100644 --- a/docs/user/guides/make-a-composition.md +++ b/docs/user/guides/make-a-composition.md @@ -2,7 +2,7 @@ title: Make your first composition description: Create a composition, add a text layer and save the project as an .ecproj file. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- Choose **Composition ▸ New Composition...**, set the size, frame rate and duration, click **OK**, then add layers from the **Layer ▸ New** menu. Shortcuts below use Ctrl on Windows and Linux. On macOS, use Cmd. @@ -13,7 +13,7 @@ Choose **Composition ▸ New Composition...**, set the size, frame rate and dura 2. In the dialog, type a **Composition Name**. The default is `Comp 1`. 3. Pick a **Preset**, or set **Width**, **Height**, **Frame Rate** and **Duration**. The presets include `HDTV 1080 24`, `UHD 4K 25` and `Social Media Square · 1080x1080 · 30 fps`. 4. Click **OK**. The composition opens in the Composition panel and the Timeline. -5. Choose **Layer ▸ New ▸ Text**. A text layer appears at the top of the Timeline. +5. Choose **Layer ▸ New ▸ Text** and type your text. A text layer appears at the top of the Timeline, and its name follows what you type. 6. Add more layers from **Layer ▸ New**: **Solid...**, **Shape Layer**, **Null Object**, **Adjustment Layer**, **Light...** and **Camera...**. 7. Choose **File ▸ Save** to save the project. @@ -23,6 +23,8 @@ Projects are saved as `.ecproj` files. An `.ecproj` file is readable JSON, so it Choose **Composition ▸ Composition Settings...** to change the name, size, frame rate or duration. The dialog has **Basic**, **Advanced** and **3D Renderer** tabs. +To keep a size and frame rate for later, click the **+** button next to **Preset** (**Save Preset…**) and name it. Your presets are listed above the built-in ones, in this dialog and in **New Composition...**. To remove one, select it and click the bin button (**Delete Preset**). + ## Learn in the app The in-app tutorial **Animate a title** walks through a similar title animation. To open it, choose **Help ▸ In-App Tutorials...**. diff --git a/docs/user/guides/render-a-video.md b/docs/user/guides/render-a-video.md index 35c8a1c92..42fa5d0a5 100644 --- a/docs/user/guides/render-a-video.md +++ b/docs/user/guides/render-a-video.md @@ -2,7 +2,7 @@ title: Render a video description: Add a composition to the Render Queue, choose a format such as H.264, pick where to save and click Render. order: 40 -verified: 0.8.0 +verified: 0.9.2 --- Select the composition, choose **Composition ▸ Add to Render Queue** (Ctrl+M), set the output file, then click **Render** in the Render Queue panel. The default format is H.264, saved as an `.mp4` file named after the composition. @@ -25,22 +25,23 @@ Tick **Notify** to get a sound and a notification when the queue finishes. The **Output Module:** menu lists the formats your build can export: -| Menu entry | File type | -|-----------------------|-------------------------------------| -| H.264 | `.mp4` | -| QuickTime (ProRes) | `.mov` | -| HEVC (H.265) | `.mp4` | -| AV1 | `.mp4` | -| WebM (VP9 + Opus) | `.webm` | -| PNG Sequence | `.png`, one file per frame | -| JPEG Sequence | `.jpg`, one file per frame | -| TIFF Sequence | `.tif`, one file per frame | -| OpenEXR Sequence | `.exr`, one file per frame | -| Animated GIF | `.gif` | -| WAV | `.wav`, audio only | -| AIFF | `.aif`, audio only | +| Menu entry | File type | +|--------------------------|-----------------------------| +| H.264 | `.mp4` | +| QuickTime (ProRes) | `.mov` | +| QuickTime (Uncompressed) | `.mov` | +| HEVC (H.265) | `.mp4` | +| AV1 | `.mp4` | +| WebM (VP9 + Opus) | `.webm` | +| PNG Sequence | `.png`, one file per frame | +| JPEG Sequence | `.jpg`, one file per frame | +| TIFF Sequence | `.tif`, one file per frame | +| OpenEXR Sequence | `.exr`, one file per frame | +| Animated GIF | `.gif` | +| WAV | `.wav`, audio only | +| AIFF | `.aif`, audio only | -To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. +To keep transparency, choose **Channels: RGB + Alpha** in the **Output Module:** menu. It appears for formats that can store alpha, such as QuickTime (ProRes), QuickTime (Uncompressed) and PNG Sequence. H.264, HEVC, AV1 and JPEG Sequence cannot. ## Render only part of a composition diff --git a/docs/user/index.md b/docs/user/index.md index babeaf8bb..05ed4993c 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -1,7 +1,7 @@ --- title: EffectCraft description: EffectCraft is a free, open-source app for motion graphics and visual effects, an alternative to Adobe After Effects. -verified: 0.8.0 +verified: 0.9.2 --- EffectCraft is a free, open-source app for animated titles, motion graphics and compositing. It is an alternative to Adobe After Effects. diff --git a/docs/user/troubleshooting/footage-missing.md b/docs/user/troubleshooting/footage-missing.md index 9e859c636..7507a2ad0 100644 --- a/docs/user/troubleshooting/footage-missing.md +++ b/docs/user/troubleshooting/footage-missing.md @@ -2,7 +2,7 @@ title: Footage is missing or offline description: Find missing footage in an EffectCraft project, relink it to a folder or replace it with another file. order: 20 -verified: 0.8.0 +verified: 0.9.2 --- Choose **File ▸ Dependencies ▸ Relink Missing Footage...** and pick the folder where your files are now. For files that stay missing, choose **File ▸ Replace Footage ▸ File...**. diff --git a/docs/user/troubleshooting/gpu-not-used.md b/docs/user/troubleshooting/gpu-not-used.md index 0c56840db..7c4fb29e0 100644 --- a/docs/user/troubleshooting/gpu-not-used.md +++ b/docs/user/troubleshooting/gpu-not-used.md @@ -2,7 +2,7 @@ title: EffectCraft does not use my graphics card description: What to do when the report says GPU none (CPU compositing), the window picked another adapter, or GPU effects fail on Windows 11 with DirectX 12. order: 10 -verified: 0.8.0 +verified: 0.9.2 --- Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Window graphics:** lines. If the window runs on the wrong adapter, set **Window Graphics** to **Automatic** in the preferences (**Edit ▸ Preferences ▸ Startup & Repair...**; on macOS: **EffectCraft ▸ Settings... ▸ Startup & Repair...**) and restart EffectCraft. @@ -23,15 +23,17 @@ Open **Help ▸ System Compatibility Report...** and read the **GPU:** and **Win One user whose GPU was not detected on 0.8.0 fixed it this way: they set the window graphics to Automatic, and EffectCraft started through Vulkan ([issue #596](https://github.com/storytold/effectcraft/issues/596)). +If the window runs on your card but the report still says `GPU: none (CPU compositing)`, the reason after it says why the GPU compositor did not start. With **OpenGL (compatibility)**, compositing always runs on the CPU, because the GPU compositor needs DirectX 12, Vulkan, Metal or WebGPU. + ## Why this happens When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL by itself. That keeps the app usable. Setting **Window Graphics** to **Automatic** lets EffectCraft choose the graphics backend again. -The **GPU Information...** button on the Previews page of the preferences shows fixed text in 0.8.0. It does not name your GPU. Use the System Compatibility Report instead. +The **GPU Information...** button on the Previews page of the preferences names the adapter the GPU compositor runs on. When compositing runs on the CPU, it says why after `GPU compositing is off:`. It also shows the `Window graphics:` line. For a bug report, still send the whole System Compatibility Report. ## Windows 11 with DirectX 12 -On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is open as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. +On some Windows 11 PCs with NVIDIA GPUs, the GPU compositor fails to start on DirectX 12 because the FXC shader compiler rejects some of EffectCraft's GPU kernels. Two users reproduced this with an RTX 4060 laptop GPU and an RTX 3060. In their tests EffectCraft fell back to CPU compositing, and the same build started on Vulkan. This is still open in 0.9.2 as [issue #613](https://github.com/storytold/effectcraft/issues/613). Check the issue for updates. The window can also take a long time to appear. On 0.8.0 with an RTX 5060 Ti, one user measured 69 seconds before the window showed on DirectX 12 without a working DXC shader compiler, against 3 seconds on Vulkan, which is what Automatic picked on that PC. If you close EffectCraft during that wait, the next launch uses OpenGL and has no GPU compositor until you set **Window Graphics** back to **Automatic** (steps above). So wait for the window on the first launch. From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 023/546] Render: drop the blanket layer cover from effect stacks; effects pay only what they need (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #659 every text or shape layer with an enabled video effect grew its effect buffer to the comp-sized layer rectangle before the stack ran, so glow and blur — which only need their own margins — allocated a 1920x1080 RGBA32F texture per layer per frame in flight (~5 GB peak VRAM in parallel export, measured on an RTX 3090). Effects that actually place pixels outside the painted content already grow the buffer themselves by exactly what they need (CC Tiler, CC Smear, CC Mosaic sharp via layer_cover_pad / Buf::pad), on CPU and GPU. The blanket target.cover(layer_size) in Renderer::run_effects_on, the FxTarget::cover trait method with its CPU default, and the GPU GpuTarget::cover texture allocation are removed; Buf::cover/cover_plan stay as the effects-crate helpers they were designed as. The #659 regression tests (tiling across the layer region, centred-shape edge pinning) and the render/effects/gpu suites pass unchanged. --- crates/gpu/src/effects.rs | 15 --------------- crates/render/src/lib.rs | 24 +++++------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..426931d78 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -145,21 +145,6 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } - - /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no - /// readback. A region the device can't allocate leaves the buffer as it is. - fn cover(&mut self, size: [f64; 2]) { - let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } - } - _ => b, - }; - self.b = Some(out); - } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..d9c87af9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,16 +490,6 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); - /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own - /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. - /// Authored buffers (text, shapes) only span their painted content; this gives the stack - /// the room a sized source's buffer already has (#659). - fn cover(&mut self, size: [f64; 2]) { - self.cpu(&mut |mut buf| { - buf.cover(size); - buf - }); - } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1132,15 +1122,11 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); - } + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From 9a202b133ae15a232090e2468143a351e8c1e7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Kozlovsk=C3=BD?= <100806068+MatejKozlovsky@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:53:10 +0200 Subject: [PATCH 024/546] Render: effect stacks keep the cover's pixel-grid phase, only known-margin stacks skip the cover, and displacement and Repeat Edge Pixels get the room the cover gave them (#1205) --- crates/effects/src/distort2.rs | 3 ++ crates/effects/src/lib.rs | 53 ++++++++++++++++++++++++++++++++++ crates/effects/src/misc.rs | 6 ++++ crates/gpu/src/effects.rs | 46 +++++++++++++++++++++++++++++ crates/gpu/src/fx_distort.rs | 3 ++ crates/render/src/lib.rs | 38 +++++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 10ec59735..4f09b95e2 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -93,6 +93,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..a968f1aad 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -286,6 +286,32 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + if left == 0 && top == 0 { + return; + } + let src = std::mem::replace(&mut self.img, Image::new(0, 0)); + let (w, h) = (src.width + left, src.height + top); + let mut out = Image::new(w, h); + let sw = src.width as usize; + for y in 0..src.height as usize { + let d = (y + top as usize) * w as usize + left as usize; + out.data[d..d + sw].copy_from_slice(&src.data[y * sw..(y + 1) * sw]); + } + self.img = out; + self.offset[0] += left as f64; + self.offset[1] += top as f64; + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side. pub fn pad(&mut self, pad: u32) { if pad == 0 { @@ -301,6 +327,33 @@ impl Buf { /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer /// already covers that region unchanged (footage, solids, the comp below an adjustment). +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + pub fn cover_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { if scale <= 0.0 || !scale.is_finite() || !offset[0].is_finite() || !offset[1].is_finite() || !size[0].is_finite() || !size[1].is_finite() { return None; diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index c119118db..fed4ce45c 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = gaussian_blur(&b.img, s * kx, s * ky, repeat || ctx.adjustment); b @@ -49,6 +52,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((r * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } b.img = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, repeat || ctx.adjustment); b diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 426931d78..4bf503940 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -23,6 +23,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -145,6 +159,32 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { let out = f(Buf { img, offset: b.offset, scale: b.scale }); self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); } + + /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no + /// readback. A region the device can't allocate leaves the buffer as it is. + fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { + let Some(b) = self.b.take() else { return }; + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } + } + _ => b, + }; + self.b = Some(out); + } } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { @@ -249,6 +289,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -264,6 +307,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index a62ad6831..e4e17c64c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -177,6 +177,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9c87af9f..041052b8e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,6 +490,24 @@ pub trait FxTarget { fn gpu(&mut self, steps: &[FxStep]) -> bool; /// Run one CPU effect: `f` maps the buffer. fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); + /// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own + /// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`]. + /// Authored buffers (text, shapes) only span their painted content; this gives the stack + /// the room a sized source's buffer already has (#659). + fn cover(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.cover(size); + buf + }); + } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). @@ -1126,7 +1144,25 @@ impl<'a> Renderer<'a> { // place pixels outside it grow the buffer themselves, by exactly what they need // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, - // per frame in flight, for glow and blur that never use the extra room. + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { + Some(effect.as_str()) + } + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + target.align(layer_size); + } else { + target.cover(layer_size); + } + } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) { From f51b4b7e1be5310fab102a8f4d507b33775fdadd Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 10:52:40 -0400 Subject: [PATCH 025/546] 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 026/546] 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 027/546] 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 028/546] 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 029/546] 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 030/546] 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 031/546] 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 032/546] 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 033/546] 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 034/546] 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 035/546] 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 036/546] 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 037/546] 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 038/546] 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 864b9e4ede6bde47e0577264df724e3517417d90 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:12:15 -0400 Subject: [PATCH 039/546] 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 040/546] 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 041/546] 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 042/546] 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 7b0771fe5acd5f69e8e6fe34722a88db2f09ddc3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 043/546] 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 044/546] 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 045/546] 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 046/546] 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 047/546] 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 048/546] 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 049/546] 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 050/546] Docs: architecture.md notes CC Particle World's Birth Rate history and checkpoint checks (#1373) --- docs/architecture.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22a..1e0a2e804 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,6 +368,10 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). +CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a +keyed rate only decides the births of its own time; the rate is left out of the simulation key, +and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +checkpoint's births). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an adjustment-layer comp. From b51c91a126e9a3a100057bd51132f7060d6a0c63 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 12:14:27 +1100 Subject: [PATCH 051/546] Render: ask the effect spec whether a stack can skip the layer cover Matej's follow-up kept the known-margin set as SKIPS_COVER and the renderer looked the ids up. Ask EffectSpec::skips_cover instead so a new margin effect opts in on the spec rather than in run_effect_stack. Tests pin align_plan to cover_shift mod 64 and that Twirl still takes the full cover. --- crates/effects/src/lib.rs | 29 +++++++++++++++++++++++++++++ crates/render/src/lib.rs | 6 ++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a968f1aad..cf59a56c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -514,6 +514,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1504,4 +1513,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 041052b8e..313cb73ff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1150,14 +1150,12 @@ impl<'a> Renderer<'a> { // get the cover: they move content past the painted bounds without growing the buffer. let video = || { fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { - GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)) => { - Some(effect.as_str()) - } + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), _ => None, }) }; if !adjustment && video().next().is_some() { - if video().all(|id| effectcraft_effects::SKIPS_COVER.contains(&id)) { + if video().all(|s| s.skips_cover()) { target.align(layer_size); } else { target.cover(layer_size); From f2cdad9edbc787929987470a20382d252ba7344d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:57:19 -0400 Subject: [PATCH 052/546] 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 053/546] 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 054/546] 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 055/546] 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 056/546] 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 057/546] 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 058/546] 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 059/546] 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 060/546] 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 061/546] 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 062/546] 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 063/546] 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 064/546] Docs: architecture.md names the three particle systems that sample their Birth Rate (#1373) --- docs/architecture.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 70722339d..30e2d6045 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -368,12 +368,12 @@ Playground cannons without interacting forces evolve every particle independentl backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions). -CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a -keyed rate only decides the births of its own time; the rate is left out of the simulation key, -and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU +CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every +step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time; +the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU checkpoint's births). The rate's keyframes are read at the step's exact layer time (`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional -stretch), a rate without keyframes or an expression isn't sampled, and a Particle World layer with +stretch), a rate without keyframes or an expression isn't sampled, and a layer of one of them with expressions isn't layer cached (its key can't see what an expression returned at earlier times). `effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel agreement and the steady-state upload / readback MB per frame for every comp plus an From 59b144439ef5259f235b854ff49da041d5be5c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:10 +0000 Subject: [PATCH 065/546] #1436: the Gradient Editor previews live and leaves the window undimmed The Gradient Editor edited a draft and set the property only on OK, in a centred dialog over a dimmed, greyed-out window, so the canvas could not show the gradient while it was edited. In After Effects the dialog sits at the lower right, the window stays at full brightness, and the canvas updates live (Preview checkbox). - Preview (on by default): each edit is applied with `prop.set` and one merged undo step; OK keeps it, Cancel and Escape roll back to a checkpoint of the project and undo history taken on open (the Layer Styles dialog's mechanism, now shared). Preview off sets the property on OK only, as before; switching it off takes back what it applied. - The editor opens at the lower right (`Placement` for modal dialogs), without the dark backdrop, and the window behind is no longer faded (`Dialog::leaves_window_undimmed`); clicks still don't reach it. - Other dialogs are unchanged. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 13 ++- crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/dialogs.rs | 34 ++++++- crates/ui-egui/src/panels/gradient_editor.rs | 88 +++++++++++++++--- .../ui-egui/src/panels/layer_styles_dialog.rs | 26 ++++-- crates/ui-egui/tests/ui_gradient_editor.rs | 93 ++++++++++++++++++- 6 files changed, 235 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..edf4a96c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,14 @@ pub enum Dialog { DeleteTemplate, } +impl Dialog { + /// The dialog edits what is behind it live (Preview), so the window stays at full brightness + /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). + pub fn leaves_window_undimmed(self) -> bool { + matches!(self, Dialog::GradientEditor) + } +} + /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -1680,8 +1688,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if self.dialog.is_some() { + if let Some(d) = self.dialog { ui.disable(); + if d.leaves_window_undimmed() { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..b104f0b9a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1785,6 +1785,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -1902,6 +1905,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only crate::panels::forms::close_info(app); continue; } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + continue; + } // Escape in Settings is Cancel: restore the settings from when it opened. if app.dialog == Some(crate::Dialog::Settings) && let Some(p) = app.dialog_state.prefs_snapshot.take() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f0e44368e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -188,16 +213,21 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let size = size.min(modal_space(ctx)); let id = egui::Id::new(("modal", title)); // Dragging the title row moves the dialog off what it edits. The offset is kept within the // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bb6923025..bd20c28cd 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +63,12 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +231,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -230,6 +244,11 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); + // The preview already set the property, in one merged undo step: OK keeps it. + if d.applied.is_some() { + app.session.history.merge_key = None; + return; + } let g = d.gradient(); let Some(tg) = d.target else { return }; if g == d.baseline { @@ -241,6 +260,49 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + restore(app, cp); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => app.dialog_state.gradient.applied = Some(g), + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + restore(app, cp); + app.dialog_state.gradient.applied = None; + } +} + fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -252,7 +314,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -410,6 +474,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -421,12 +489,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..440784b7e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,8 +23,9 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { +pub(crate) struct Checkpoint { project: std::sync::Arc, history: effectcraft_engine::history::History, } @@ -35,7 +36,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -257,14 +258,21 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_state(); - if changed { - app.session.bump(); - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } + +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_state(); + if changed { + app.session.bump(); + } + app.session.history.merge_key = None; +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index fa8c7b73f..7d8f46281 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,83 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] @@ -491,3 +571,12 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } + +/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while +/// it is open; other dialogs still are. +#[test] +fn only_dialogs_that_preview_leave_the_window_undimmed() { + assert!(Dialog::GradientEditor.leaves_window_undimmed()); + assert!(!Dialog::SolidSettings.leaves_window_undimmed()); + assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); +} From 25a752baa020faecdd4617ed955d5e5672b03031 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:04:30 +0000 Subject: [PATCH 066/546] #1436: Solid, Camera, Light and Composition Settings preview live The Gradient Editor's Preview now covers the other settings dialogs that edit something that exists: Solid Settings on a solid layer, Camera Settings, Light Settings and Composition Settings. A change of the draft reaches the project at once, so the canvas shows it while it is made. - One shared mechanism (`panels::live_preview`): each step goes back to a checkpoint of the project and undo history taken when the dialog opened, then runs the dialog's command once. So the project is always "as it opened, plus one command": OK leaves one undo step, Cancel and Escape restore the checkpoint, Preview off applies nothing until OK, and switching it off takes back what was applied. - A previewing dialog opens at the lower right, without the dark backdrop, and the window behind it is no longer greyed out; it can still be dragged by its title. The same dialogs creating something new (New Solid, New Camera, New Light, New Composition) are unchanged and have no Preview. - `Placement` is public, and `EffectcraftApp::dialog_state` gives tests and automation the drafts. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 26 +- crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/panels/comp_settings.rs | 4 + crates/ui-egui/src/panels/dialogs.rs | 55 +++- crates/ui-egui/src/panels/dialogs_3d.rs | 159 +++++++---- crates/ui-egui/src/panels/gradient_editor.rs | 3 +- crates/ui-egui/src/panels/live_preview.rs | 117 ++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_dialog_preview.rs | 268 +++++++++++++++++++ crates/ui-egui/tests/ui_gradient_editor.rs | 9 - crates/ui-egui/tests/ui_project_rows.rs | 5 +- 11 files changed, 568 insertions(+), 82 deletions(-) create mode 100644 crates/ui-egui/src/panels/live_preview.rs create mode 100644 crates/ui-egui/tests/ui_dialog_preview.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index edf4a96c3..592be9255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,14 +101,6 @@ pub enum Dialog { DeleteTemplate, } -impl Dialog { - /// The dialog edits what is behind it live (Preview), so the window stays at full brightness - /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). - pub fn leaves_window_undimmed(self) -> bool { - matches!(self, Dialog::GradientEditor) - } -} - /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -578,6 +570,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Feed this frame's share of the queued synthetic input (control channel `ui.click`, /// `ui.drag`…) into `raw_input`. Pointer events go one per frame so egui sees press → moves /// → release as a real gesture. @@ -1688,9 +1692,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if let Some(d) = self.dialog { + if self.dialog.is_some() { ui.disable(); - if d.leaves_window_undimmed() { + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { ui.set_opacity(1.0); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b104f0b9a..5d2479bb2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1800,6 +1800,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } return; @@ -1916,6 +1918,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only app.session.prefs = p; app.session.prefs_changed(); } + crate::panels::live_preview::cancel(app); app.dialog = None; continue; } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..005b2bce7 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -248,6 +248,7 @@ pub fn show( auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, ) -> Option { let mut out = None; ui.horizontal(|ui| { @@ -470,6 +471,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f0e44368e..3e262bd89 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -65,6 +65,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -75,6 +77,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -107,6 +110,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -121,6 +125,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -136,6 +141,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -179,7 +185,7 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, /// Where a modal dialog opens and whether it dims the window behind it. The default is the middle /// of the window over a dimmed backdrop. #[derive(Clone, Copy, Debug)] -pub(crate) struct Placement { +pub struct Placement { /// The opening position as a fraction of the room the dialog can move in, from the middle /// `(0, 0)` to the bottom right `(1, 1)`. pub start: egui::Vec2, @@ -412,9 +418,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -432,11 +442,19 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app) { + app.dialog = None; + return; + } let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) }; if !existing && r.is_ok() { app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone()); @@ -446,7 +464,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -494,8 +520,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::new("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -581,16 +610,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index a1a67bc60..a8d24e9a3 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -87,8 +87,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -137,13 +143,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)); r.0 = b.rect; @@ -155,6 +167,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -163,8 +178,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); egui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -217,46 +235,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -314,48 +352,65 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.add_space(14.0); ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim)); ui.add_space(8.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = KINDS[d.kind.min(3)]; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if kind != LightKind::Ambient { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = KINDS[d.kind.min(3)]; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if kind != LightKind::Ambient { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bd20c28cd..fbbfc01ab 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -315,8 +315,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } let mut answer: Option = None; // At the lower right, undimmed: the canvas it edits stays in view (After Effects). - let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; - super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, place, |ui| { + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..861f8eb9f --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,117 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint, restore}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => app.dialog_state.live.applied = Some((command, params)), + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + restore(app, cp); + } + app.dialog_state.live.applied = None; +} + +/// OK: the preview's result is the edit. Returns whether it already is (the caller then must not +/// run the command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp) -> bool { + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..65cb199d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; diff --git a/crates/ui-egui/tests/ui_dialog_preview.rs b/crates/ui-egui/tests/ui_dialog_preview.rs new file mode 100644 index 000000000..861548012 --- /dev/null +++ b/crates/ui-egui/tests/ui_dialog_preview.rs @@ -0,0 +1,268 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index 7d8f46281..3b5da558d 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -571,12 +571,3 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } - -/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while -/// it is open; other dialogs still are. -#[test] -fn only_dialogs_that_preview_leave_the_window_undimmed() { - assert!(Dialog::GradientEditor.leaves_window_undimmed()); - assert!(!Dialog::SolidSettings.leaves_window_undimmed()); - assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); -} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog"); From 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 067/546] 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 068/546] #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 93b06a6eab9c1636524be8173d95b074f02b20b8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 22:56:38 +0200 Subject: [PATCH 069/546] Bump FilmCraft to 4e2d0bc2 (part of #804) Problem: the hardware-decoding work (#819) needs a newer FilmCraft than the pinned 2c5ba7a, and the pin bump should be reviewable apart from it. Fix: move all filmcraft-* deps to 4e2d0bc2. Adapt to MediaInfo.audio -> audio_streams and VideoStreamInfo.hdr; ask the pool for a quarter into the frame because FilmCraft now rounds requests half up; adapt rotation and the AAC downmix; use Decoder::with_threads(1) in the VP9 tests. No filmcraft-platform, no hardware setting. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 115 ++++++++++++++------------ Cargo.toml | 32 +++---- crates/export/tests/vp9_roundtrip.rs | 6 +- crates/host/tests/timeline_interop.rs | 3 +- crates/interchange/src/export.rs | 6 +- crates/interchange/src/import.rs | 4 +- crates/interchange/src/tests.rs | 6 +- crates/media/src/downmix.rs | 39 ++++++++- crates/media/src/pool.rs | 11 ++- crates/media/src/probe.rs | 15 +++- 10 files changed, 151 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce8fe1231..98e00791d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2025,8 +2025,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2034,16 +2034,26 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-apv" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-av1" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2052,21 +2062,22 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-cfb" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-codecs" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-aac", "filmcraft-ac3", + "filmcraft-apv", "filmcraft-av1", "filmcraft-bitstream", "filmcraft-color", @@ -2094,16 +2105,16 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-dnx" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2112,8 +2123,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2123,16 +2134,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2152,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2151,8 +2162,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2161,8 +2172,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2178,8 +2189,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2187,13 +2198,13 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-media" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2206,8 +2217,8 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2215,31 +2226,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-ogg" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-opus" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" [[package]] name = "filmcraft-project" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2252,8 +2263,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2262,16 +2273,16 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "serde", ] [[package]] name = "filmcraft-vp9" -version = "0.1.1" -source = "git+https://github.com/storytold/filmcraft?rev=2c5ba7a72619935f0481f99d59409e0d1844b234#2c5ba7a72619935f0481f99d59409e0d1844b234" +version = "0.6.0" +source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 9f4e9d17f..cb8b7ea38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,26 +71,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "2c5ba7a72619935f0481f99d59409e0d1844b234" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/export/tests/vp9_roundtrip.rs b/crates/export/tests/vp9_roundtrip.rs index e7c2b0346..1432e3892 100644 --- a/crates/export/tests/vp9_roundtrip.rs +++ b/crates/export/tests/vp9_roundtrip.rs @@ -41,7 +41,7 @@ fn source(w: usize, h: usize, kind: u32) -> (Vec, Vec, Vec) { } fn decode(frame: &[u8]) -> filmcraft_vp9::Picture { - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let mut pics = d.decode(frame, 0).expect("decode"); assert_eq!(pics.len(), 1); pics.remove(0) @@ -189,7 +189,7 @@ fn planes_of(p: &filmcraft_vp9::Picture, w: usize, h: usize) -> [Vec; 3] { fn run_sequence(cfg: EncoderConfig, n: usize, kind: u32) -> (usize, f64) { let (w, h) = (cfg.width as usize, cfg.height as usize); let mut e = Vp9Encoder::new(cfg); - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); let (mut bytes, mut db) = (0usize, 0.0); for t in 0..n { let (y, u, v) = moving(w, h, t, kind); @@ -344,7 +344,7 @@ fn frames_never_end_in_a_superframe_marker() { } let Some((w, h, q, frames, recon)) = padded else { panic!("no frame needed the padding: widen the search") }; // The padded frames decode exactly, with FilmCraft's decoder and with ffmpeg. - let mut d = Decoder::new(); + let mut d = Decoder::with_threads(1); for (t, (f, r)) in frames.iter().zip(&recon).enumerate() { let mut pics = d.decode(f, t as i64).unwrap_or_else(|e| panic!("{w}x{h} q{q} frame {t}: {e:?}")); assert_eq!(planes_of(&pics.remove(0), w, h), *r, "{w}x{h} q{q} frame {t}"); diff --git a/crates/host/tests/timeline_interop.rs b/crates/host/tests/timeline_interop.rs index 5123dc721..9688a409f 100644 --- a/crates/host/tests/timeline_interop.rs +++ b/crates/host/tests/timeline_interop.rs @@ -56,8 +56,9 @@ fn still(p: &mut fp::Project, path: &str) -> fp::ItemId { color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: None, + audio_streams: Vec::new(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..da6ef1382 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -684,8 +684,12 @@ fn media_info(name: &str, kind: MediaKind, duration: Tick, video: Option { MediaKind::AudioOnly => FootageKind::Audio, MediaKind::Still => FootageKind::Still, MediaKind::ImageSequence => FootageKind::Sequence, - _ if v.is_none() && info.audio.is_some() => FootageKind::Audio, + _ if v.is_none() && info.has_audio() => FootageKind::Audio, _ => FootageKind::Video, }; let _ = name; @@ -284,7 +284,7 @@ impl Importer<'_> { frame_rate: rate_in(info.frame_rate()), duration: tick_in(info.duration), has_video: kind != FootageKind::Audio, - has_audio: info.audio.is_some() || kind == FootageKind::Audio, + has_audio: info.has_audio() || kind == FootageKind::Audio, alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, codec: v.map(|v| v.codec.clone()).unwrap_or_default(), missing: true, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..6e29c79d2 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -36,8 +36,12 @@ fn info(kind: filmcraft_media::MediaKind, video: bool, audio: bool) -> filmcraft color: Default::default(), has_alpha: false, bitrate: None, + hdr: None, }), - audio: audio.then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }), + audio_streams: audio + .then(|| filmcraft_media::AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "pcm".into(), bits_per_sample: Some(16) }) + .into_iter() + .collect(), container: String::new(), start_timecode: None, file_size: None, diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index 2ef417dd7..e8a9237e0 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -64,11 +64,30 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { out } +/// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 +/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, +/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +pub fn decoded_aac_gains(count: usize) -> Vec { + match count { + // C L R Cs, reordered to L R C Cs: the same gains, permuted. + 4 => { + let g = stereo_gains(4, true); + [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() + } + 3 | 5 | 6 => stereo_gains(count, false), + _ => stereo_gains(count, true), + } +} + /// Downmix planar `channels` to interleaved stereo in `out` (`out.len() / 2` frames); frames a /// channel is too short for count as silence, extra frames in `out` stay as they are. pub fn downmix_into(channels: &[Vec], aac: bool, out: &mut [f32]) { - let gains = stereo_gains(channels.len(), aac); - for (ch, g) in channels.iter().zip(&gains) { + let gains = if aac { decoded_aac_gains(channels.len()) } else { stereo_gains(channels.len(), false) }; + mix(channels, &gains, out); +} + +fn mix(channels: &[Vec], gains: &[Pair], out: &mut [f32]) { + for (ch, g) in channels.iter().zip(gains) { if g[0] == 0.0 && g[1] == 0.0 { continue; } @@ -98,6 +117,22 @@ mod tests { } } + #[test] + fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { + // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. + for n in [3, 5, 6] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); + } + let g = decoded_aac_gains(4); + assert_eq!(g.len(), 4); + assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); + assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); + assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); + for n in [1, 2, 7, 8, 9] { + assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); + } + } + #[test] fn five_one_matches_the_bs775_matrix() { let k = 1.0 / (1.0 + 2.0 * FRAC_1_SQRT_2); diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 98c707108..33fb0b26f 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -509,7 +509,7 @@ impl MediaPool { let path: Arc = footage.path.as_str().into(); let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); - let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); + let native = src.info().audio().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { return read_stereo(&src, s0, frames, rate); } @@ -700,9 +700,12 @@ impl Inner { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); let i = Self::frame_index(rate, t, n, footage.loop_count); - // Middle of the frame in the file's own timing (robust to timestamp rounding). + // A quarter into the frame in the file's own timing: FilmCraft rounds a request to + // the track's nearest timestamp unit (halves up), so the middle of a frame would + // land on the next one when the timescale is the frame rate; a quarter still + // tolerates timestamps rounded either way. let native = footage.native_rate.unwrap_or(rate); - let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); + let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 4); Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } @@ -915,7 +918,7 @@ fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::re } let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; - let aac = src.info().audio.as_ref().is_some_and(|a| a.codec.starts_with("AAC")); + let aac = src.info().audio().is_some_and(|a| a.codec.starts_with("AAC")); crate::downmix::downmix_into(&buf.channels, aac, dst); Ok(out) } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..7c5d1c491 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -119,7 +119,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - (_, None) => FootageKind::Audio, }; let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate)); - let codec = match (v, info.audio.as_ref()) { + let codec = match (v, info.audio()) { (Some(v), _) => v.codec.clone(), (None, Some(a)) => a.codec.clone(), _ => String::new(), @@ -134,7 +134,7 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - native_rate: None, duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) }, has_video: v.is_some(), - has_audio: info.audio.is_some(), + has_audio: info.has_audio(), alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore }, premul_color: [0.0; 3], loop_count: 1, @@ -239,12 +239,19 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option) -> im /// The rotation an MP4/MOV movie is shown with (a phone held upright records sideways frames and /// says so in its video track's header): the quarter turn nearest the track's rotation. Only the /// rotation counts; any mirroring, scaling or offset in the header is ignored, and other -/// containers have none. +/// containers have none. FilmCraft turns the frames of a pure 0/90/180/270° header itself, so this +/// is only the quarter turn nearest to a header it does not turn. pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { let Ok(file) = filmcraft_isobmff::open(bytes) else { return Orientation::NoTransforms }; - let Some(m) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video).map(|t| t.matrix) else { + let Some(track) = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video) else { return Orientation::NoTransforms; }; + // A pure rotation is already applied by FilmCraft's decoder (frames and size come out + // upright); only the headers it leaves as stored (mirrored, scaled, sheared) are turned here. + if track.display_rotation().is_some() { + return Orientation::NoTransforms; + } + let m = track.matrix; // [a b u; c d v; x y w]: (a, b) is where the picture's x axis points. let (a, b) = (m[0] as i64, m[1] as i64); match (a.abs() >= b.abs(), a >= 0, b >= 0) { From 7b3d3455cb7bb66ddf30ac2c4af0edfdfbf883b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 02:35:07 +1100 Subject: [PATCH 070/546] UI: rescan system fonts from the font menu; Linux scan smoke test (#1089) Fonts installed after EffectCraft launched (dnf, GNOME Font Viewer, a drop into ~/.local/share/fonts) stayed invisible until restart: only the startup background scan ran, and the report in #1089 could not tell discovery gaps apart from a stale scan. - The font menu's filter row gains a Rescan Fonts button (Character and Properties panels share the popup) that runs the existing text.fonts {rescan} command and reports the number added in the status line. The press doesn't flip the Variable-fonts filter (the button is above the row's click area and closes the popup on top of it). - system_scan_finds_fonts now asserts, on a native fontconfig desktop (any machine with /etc/fonts, which includes the Fedora reporter's and the CI runner), that the scan lists installed families rather than silently returning only the bundled fonts: 870 faces in ~10 ms on an Arch box. Tests: ui_font_search 4 passed (new rescan test drives the real popup); effectcraft-text lib system scan green; fmt and clippy --all-targets -D warnings clean. Translations for 'Rescan Fonts' in all six catalogs. --- crates/text/src/fonts.rs | 5 +++++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/panels/text_panels.rs | 11 +++++++++++ crates/ui-egui/tests/ui_font_search.rs | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..98ed85493 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -943,6 +943,11 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { + // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must + // list the installed system families, not just the bundled three. + assert!(n > 20, "fontconfig system fonts missing: {n}"); + } if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 58cbe8349..d554c45fa 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -351,6 +351,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -893,6 +894,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1626,6 +1628,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -2368,6 +2371,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -2895,6 +2899,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3349,6 +3354,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -3926,6 +3932,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index ae2294f1f..631a75eba 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -642,6 +642,17 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } + // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear + // without restarting EffectCraft: the system scan also runs in the background at + // startup, this re-runs it on demand. + let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { + match app.session.execute("text.fonts", json!({"rescan": true})) { + Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), + Err(e) => app.ui.status = e.to_string(), + } + } + app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); let mut seen = std::collections::HashSet::new(); let filtered: Vec<_> = rows diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index 6be69cb7a..0d9c7b79a 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -176,3 +176,24 @@ fn font_menu_filters_variable_fonts() { key(&mut h, Key::Escape); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} From f676bf08204dc261e9b8f8d978307a0d538e8022 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Fri, 16 Oct 2026 22:20:10 +1100 Subject: [PATCH 071/546] Keep the font rescan off the filter label and off the UI thread. The 180 px font menu pinned Rescan Fonts over "Variable fonts only", and the click walked every fontconfig directory on the frame thread. The button is now its own row, and the scan runs on a background thread that the frame polls. The Linux face-count assert is left to the native-fonts gate. --- crates/text/src/fonts.rs | 64 +++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/text_panels.rs | 29 +++++++---- 3 files changed, 78 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 98ed85493..4f6dceb13 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -360,7 +360,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(|e| e.into_inner()); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -368,6 +369,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(|e| e.into_inner()); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. @@ -943,11 +998,8 @@ mod tests { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); - if cfg!(all(unix, not(target_os = "macos"), not(target_arch = "wasm32"))) && std::path::Path::new("/etc/fonts").exists() { - // #1089: a fontconfig desktop (Fedora, Arch, Ubuntu, the CI runner): the scan must - // list the installed system families, not just the bundled three. - assert!(n > 20, "fontconfig system fonts missing: {n}"); - } + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..108c6db23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1871,6 +1871,7 @@ impl eframe::App for EffectcraftApp { // Settings ▸ General ▸ Language instead of only the first frame. let language = crate::i18n::language(self); crate::i18n::set_current(language); + crate::panels::text_panels::poll_font_rescan(self, ctx); if !self.styled || self.styled_language != language { theme::install(ctx, &self.tokens, language); self.styled_language = language; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 631a75eba..c6ade5634 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -568,6 +568,17 @@ struct FontSearch { selected: usize, } +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + /// Shared font menu for Character and Properties: search, recent fonts and previews. pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); @@ -642,15 +653,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: // Typing goes on into the search field. ui.memory_mut(|m| m.request_focus(search_id)); } - // #1089: fonts installed after launch (dnf, GNOME Fonts, a drop into ~/.fonts) appear - // without restarting EffectCraft: the system scan also runs in the background at - // startup, this re-runs it on demand. - let resc = Rect::from_min_size(pos2(row.rect.max.x - 124.0, row.rect.min.y + 2.0), vec2(120.0, filter_h - 4.0)); - if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() { - match app.session.execute("text.fonts", json!({"rescan": true})) { - Ok(v) => app.ui.status = format!("{} +{}", tr("Rescan Fonts"), v.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0)), - Err(e) => app.ui.status = e.to_string(), - } + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app.ui.status = format!("{}…", tr("Rescan Fonts")); } app.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let query = state.query.trim().to_lowercase(); @@ -683,7 +692,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } // Give the result list its actual height even when the previous query had // only one row. A cached tiny Area must not collapse a reopened long list. - let filter_room = filter_h + ui.spacing().item_spacing.y; + let filter_room = filter_h + rescan_h + ui.spacing().item_spacing.y * 2.0; let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0)); // A minimum, from below the filter row (a maximum would move the cursor back up). ui.set_min_height(height); From 912f828aa3e66cbc3ec3ad41996bb3dec800ea9d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:47 +0300 Subject: [PATCH 072/546] project: output_size never asks for sides past the comp limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 30 000 px comp (the repaired MAX_SIZE on load) at resolution 4.0 produced 120 000 px sides, which overflow u32 in the exporters' w * h * 4 (debug panic, release wrap then slice panic) after already requesting a ~57 GB frame. The resize path in the same file clamps to 30 000; output_size now does too. Regression test: output_size_never_exceeds_the_comp_limit — on main it returns (120000, 120000); with the clamp (30000, 30000), through both RenderSettings and the OutputModule wrapper. --- crates/project/src/render_queue.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..e49c5d1cc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -444,7 +444,11 @@ impl RenderSettings { } pub fn output_size(&self, comp: &Comp) -> (u32, u32) { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing + // `w * h * 4` in the exporters. The resize path clamps to the same limit. + let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); + let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -1663,6 +1667,16 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). + let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + assert_eq!(rs.output_size(&c), (30_000, 30_000)); + let m = OutputModule::for_format(OutputFormat::PngSequence); + assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); From 30e02b06caef9ca3d6bfc672bbd4110204d86db4 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:46:24 +0300 Subject: [PATCH 073/546] project: output_size clamps the scale, not the sides, and every renderer uses it Clamping each side on its own broke the aspect ratio (a 30000x15000 comp at 4x came out 30000x30000). The effective scale min(s, MAX_SIZE/w, MAX_SIZE/h) keeps both sides within the comp limit, and the export pipeline and the locked-aspect width derivation render at that same scale, so nothing requests a frame larger than output_size reports. --- crates/engine/src/commands/render_queue.rs | 2 +- crates/export/src/pipeline.rs | 4 +-- crates/project/src/render_queue.rs | 37 ++++++++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 09a7c7d2f..39af29c42 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -601,7 +601,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c7..6877a09bf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -147,7 +147,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -231,7 +231,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index e49c5d1cc..44a7be5a4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,12 +442,17 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing - // `w * h * 4` in the exporters. The resize path clamps to the same limit. - let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); - let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); (w, h) } pub fn resolution_label(&self) -> &'static str { @@ -1120,7 +1125,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1669,12 +1674,24 @@ mod tests { #[test] fn output_size_never_exceeds_the_comp_limit() { - // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). - let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. let rs = RenderSettings { resolution: 4.0, ..Default::default() }; - assert_eq!(rs.output_size(&c), (30_000, 30_000)); let m = OutputModule::for_format(OutputFormat::PngSequence); - assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); } #[test] From 0502d2f82d93ec7ec324ceac2010c94a0a45daa1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 16:06:15 +0200 Subject: [PATCH 074/546] 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 075/546] 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 076/546] 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 be9c9077cd62232f3a6ecac2a6c2d309cfb68545 Mon Sep 17 00:00:00 2001 From: p01ntov <278898977+p01ntov@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:56:35 +0300 Subject: [PATCH 077/546] G2: tessellate viewer path overlays in screen space --- crates/ui-egui/src/panels/viewer_overlays.rs | 152 ++++++++++++++++++- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 9117103d5..d0875941e 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -679,6 +714,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { From ee17172fb688ffe1abb07de2db9c7d806f4e5198 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:50:04 -0400 Subject: [PATCH 078/546] Media: update FilmCraft to 09aaf9b, which reads AVI (#1701) Moves the pin past FilmCraft's AVI / OpenDML reader (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3). No EffectCraft API changes. --- Cargo.lock | 144 +++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 32 ++++++------ 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06da2cebd..1dea8187a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2043,8 +2043,8 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "filmcraft-aac" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2052,16 +2052,16 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-apv" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2070,33 +2070,39 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" + [[package]] name = "filmcraft-bitstream" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-cfb" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-codecs" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-aac", "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", @@ -2106,6 +2112,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2113,9 +2120,11 @@ dependencies = [ "filmcraft-opus", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp8", "filmcraft-vp9", "image", "log", + "rayon", "symphonia", "thiserror 2.0.21", "web-time", @@ -2123,16 +2132,25 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-dng" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-dnx" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2141,8 +2159,8 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -2152,16 +2170,16 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] [[package]] name = "filmcraft-h264" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2170,8 +2188,8 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2180,8 +2198,8 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2190,8 +2208,8 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2207,8 +2225,8 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "thiserror 2.0.21", @@ -2216,15 +2234,16 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-media" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", + "filmcraft-dng", "filmcraft-frame", "filmcraft-time", "image", @@ -2233,10 +2252,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "filmcraft-bitstream", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "rayon", "thiserror 2.0.21", @@ -2244,31 +2272,31 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "thiserror 2.0.21", ] [[package]] name = "filmcraft-mxf" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-ogg" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-opus" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" [[package]] name = "filmcraft-project" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2281,8 +2309,8 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2291,16 +2319,24 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "serde", ] +[[package]] +name = "filmcraft-vp8" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-vp9" -version = "0.6.0" -source = "git+https://github.com/storytold/filmcraft?rev=4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe#4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" +version = "0.8.0" +source = "git+https://github.com/storytold/filmcraft?rev=09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5#09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" dependencies = [ "filmcraft-bitstream", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 7b9983788..8810e3c16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,26 +72,26 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. -filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # encoders + muxer (crates/export) -filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-h264enc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoder used as the round-trip oracle of effectcraft-opusenc (crates/export dev-dependency) -filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-opus = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-vp9 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # decoders used as the round-trip oracles of effectcraft-hevcenc / effectcraft-av1enc (dev-dependencies only) -filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-hevc = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # timeline interchange (EDL, FCP7 XML, FCPXML, OTIO) for Premiere Pro interop (crates/interchange only) -filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } -filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "4e2d0bc2d3646f9cf27ef7aa8b2095c6858c7efe" } +filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } +filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } # The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned # rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } From 01ad187b97888efedd588130f12d20887e7a610b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:04 -0400 Subject: [PATCH 079/546] Media: drop the AIFF workaround dependency (#1701) FilmCraft's codecs now turn on symphonia's AIFF reader themselves. --- Cargo.lock | 1 - Cargo.toml | 3 --- crates/media/Cargo.toml | 2 -- 3 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dea8187a..8fc3183ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1455,7 +1455,6 @@ dependencies = [ "log", "rayon", "serde_json", - "symphonia", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 8810e3c16..b1c6caaf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,9 +92,6 @@ filmcraft-av1 = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9b filmcraft-interchange = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-project = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } filmcraft-geom = { git = "https://github.com/storytold/filmcraft", rev = "09aaf9bf15d510eda1c413c0ccabb1d7e08b41c5" } -# The symphonia filmcraft-codecs already builds, named only to turn on its AIFF reader: the pinned -# rev enables wav alone (FilmCraft main enables aiff too). Drop it when the pin moves past that. -symphonia = { version = "0.5.5", default-features = false, features = ["aiff"] } # gpu (the same wgpu eframe / egui-wgpu use) wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index bb47dccb0..a3ff624b4 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,8 +24,6 @@ filmcraft-isobmff = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } -# AIFF import (#1428): enables the aiff reader of filmcraft-codecs' symphonia. -symphonia = { workspace = true } exr = { workspace = true } gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr", "tga", "hdr"] } From ca724a3d84c8fcd1724b3de724ed4c5c4507ee53 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 10:53:05 -0400 Subject: [PATCH 080/546] Media: downmix 7.1 AAC in the order FilmCraft now delivers it (#1701) FilmCraft reorders AAC channel configuration 12 (7.1) to WAVE/SMPTE order, as it does 3 to 6, so 7.1 AAC's left channel was mixed as the centre. The downmix oracle test gains 6.1 (a program config element, left in AAC's order) and 7.1 AAC fixtures. --- crates/media/src/downmix.rs | 16 +++++++++------- crates/media/tests/downmix.rs | 10 ++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/media/src/downmix.rs b/crates/media/src/downmix.rs index e8a9237e0..1ad9721ba 100644 --- a/crates/media/src/downmix.rs +++ b/crates/media/src/downmix.rs @@ -65,8 +65,10 @@ pub fn stereo_gains(count: usize, aac: bool) -> Vec { } /// The gains for AAC as FilmCraft's decoder hands it over: channel configurations 3 to 6 (3 to 6 -/// channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs), everything else (mono, -/// stereo, 7 and 8 channels, program-config layouts) in AAC's element order. +/// channels) and 12 (7.1, 8 channels) are already in WAV/SMPTE order (L R C [Cs] / [LFE] Ls Rs, +/// L R C LFE Lb Rb Ls Rs), everything else (mono, stereo, program-config layouts such as the 6.1 +/// ffmpeg writes) in AAC's element order. Configuration 11 (6.1) and 7 (7.1 with front wides) +/// can't be told from those by the channel count and are read in the order of the commoner one. pub fn decoded_aac_gains(count: usize) -> Vec { match count { // C L R Cs, reordered to L R C Cs: the same gains, permuted. @@ -74,7 +76,7 @@ pub fn decoded_aac_gains(count: usize) -> Vec { let g = stereo_gains(4, true); [1, 2, 0, 3].iter().filter_map(|&i| g.get(i).copied()).collect() } - 3 | 5 | 6 => stereo_gains(count, false), + 3 | 5 | 6 | 8 => stereo_gains(count, false), _ => stereo_gains(count, true), } } @@ -118,9 +120,9 @@ mod tests { } #[test] - fn decoded_aac_is_in_wav_order_for_three_to_six_channels() { - // FilmCraft hands AAC 3.0 to 5.1 over as L R C [LFE] Ls Rs, the order of the plain layouts. - for n in [3, 5, 6] { + fn decoded_aac_is_in_wav_order_for_three_to_six_and_eight_channels() { + // FilmCraft hands AAC 3.0 to 5.1 and 7.1 over in the order of the plain layouts. + for n in [3, 5, 6, 8] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, false), "{n} channels"); } let g = decoded_aac_gains(4); @@ -128,7 +130,7 @@ mod tests { assert!(g[0][0] > 0.0 && g[0][1] == 0.0 && g[1][1] > 0.0 && g[1][0] == 0.0, "L R first"); assert!(g[2][0] > 0.0 && g[2][0] == g[2][1], "the centre is third and feeds both sides"); assert!(g[3][0] > 0.0 && g[3][0] < g[2][0], "the rear centre is last, below the centre"); - for n in [1, 2, 7, 8, 9] { + for n in [1, 2, 7, 9] { assert_eq!(decoded_aac_gains(n), stereo_gains(n, true), "{n} channels"); } } diff --git a/crates/media/tests/downmix.rs b/crates/media/tests/downmix.rs index e0e7d2252..e2c95c251 100644 --- a/crates/media/tests/downmix.rs +++ b/crates/media/tests/downmix.rs @@ -94,8 +94,18 @@ fn cases() -> Vec { let (n71, g71) = seven_one(); let k5 = 1.0 / (1.0 + 2.0 * K); let kq = 1.0 / (1.0 + K); + let k61 = 1.0 / (1.5 + 2.0 * K); vec![ Case { file: "dm51.m4a", layout: "5.1", names: n51, codec: &["-c:a", "aac", "-b:a", "384k"], gains: g51.clone(), tol: 0.15 }, + Case { + file: "dm61.m4a", + layout: "6.1", + names: &["FL", "FR", "FC", "LFE", "BC", "SL", "SR"], + codec: &["-c:a", "aac", "-b:a", "448k"], + gains: vec![[k61, 0.0], [0.0, k61], [k61 * K, k61 * K], [0.0, 0.0], [k61 / 2.0, k61 / 2.0], [k61 * K, 0.0], [0.0, k61 * K]], + tol: 0.15, + }, + Case { file: "dm71.m4a", layout: "7.1", names: n71, codec: &["-c:a", "aac", "-b:a", "512k"], gains: g71.clone(), tol: 0.15 }, Case { file: "dm51.ac3.mp4", layout: "5.1", names: n51, codec: &["-c:a", "ac3", "-b:a", "640k"], gains: g51.clone(), tol: 0.15 }, Case { file: "dm51.wav", layout: "5.1", names: n51, codec: &["-c:a", "pcm_s16le"], gains: g51.clone(), tol: 0.02 }, Case { file: "dm51.flac", layout: "5.1", names: n51, codec: &["-c:a", "flac"], gains: g51, tol: 0.02 }, From bed49c4959787c41e4fd5986db9683f2debe8af5 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 081/546] Media: import AVI (#1701) One list of movie extensions, in effectcraft-project, with AVI added: FilmCraft reads AVI 1.0 / OpenDML. Tests against ffmpeg fixtures: Motion JPEG with PCM audio, an unsupported codec (imports and says why it has no picture), a file that isn't an AVI (an import error), and a damaged one (a truncated recording imports as far as it goes; a garbled packet loses its own frame only). --- crates/media/src/lib.rs | 3 +- crates/media/tests/footage.rs | 135 ++++++++++++++++++++++++++++++++-- crates/project/src/lib.rs | 5 ++ 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..1b40fcfac 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -63,8 +63,7 @@ pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "tga", "hdr", "psd", "psb", "svg", "pdf", "ai", "eps"]; /// File extensions recognised as audio-only files. pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS; -/// File extensions recognised as movies. -pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf"]; +pub use effectcraft_project::VIDEO_EXTENSIONS; /// File extensions recognised as 3D models (Advanced 3D model layers). pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..8dd4db672 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -168,9 +168,14 @@ fn tone_wav() -> Option { /// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix). fn reference_frame(src: &Path, n: usize) -> Image { + reference_frame_in(src, n, "bt709") +} + +/// [`reference_frame`] of a movie whose Y'CbCr is in `matrix` (`bt601` for Motion JPEG). +fn reference_frame_in(src: &Path, n: usize, matrix: &str) -> Image { let stem = src.file_stem().expect("stem").to_string_lossy().to_string(); let name = format!("ref_{stem}_{n}.png"); - let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); + let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix={matrix}:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba"); let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg"); load_png(&p) } @@ -472,12 +477,12 @@ fn dnxhd_mxf() -> Option { ) } -/// MXF is importable footage (After Effects imports it), whatever the extension's case. +/// MXF and AVI are importable footage (After Effects imports them), whatever the extension's case. #[test] -fn mxf_is_importable() { - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.mxf"))); - assert!(effectcraft_media::is_importable(Path::new("/media/A001C003.MXF"))); - assert!(!effectcraft_media::is_importable(Path::new("/media/A001C003.avi")), "no AVI demuxer yet"); +fn mxf_and_avi_are_importable() { + for name in ["A001C003.mxf", "A001C003.MXF", "capture.avi", "CAPTURE.AVI"] { + assert!(effectcraft_media::is_importable(&Path::new("/media").join(name)), "{name}"); + } } #[test] @@ -523,6 +528,124 @@ fn mxf_dnxhd_frames() { } } +fn mjpeg_avi() -> Option { + ffmpeg_fixture( + "testsrc2_mjpeg_pcm.avi", + &[ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + "-c:v", + "mjpeg", + "-q:v", + "2", + "-pix_fmt", + "yuvj420p", + "-c:a", + "pcm_s16le", + "-ac", + "2", + ], + ) +} + +#[test] +fn avi_mjpeg_with_pcm_audio() { + let Some(p) = mjpeg_avi() else { return }; + let f = probe(&p).expect("probe"); + assert_eq!(f.kind, FootageKind::Video); + assert_eq!((f.width, f.height), (640, 360)); + assert_eq!(f.frame_rate, FrameRate::FPS_25); + assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds()); + assert!(f.has_video && f.has_audio); + assert_eq!(f.codec, "Motion JPEG"); + let pool = MediaPool::new(); + for n in [0usize, 1, 30, 7, 49] { + let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame"); + let db = psnr(&img, &reference_frame_in(&p, n, "bt601")); + assert!(db > 35.0, "frame {n}: {db:.2} dB"); + } + let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000); + assert_eq!(s.len(), 9600); + let rms = (s.iter().map(|v| v * v).sum::() / s.len() as f32).sqrt(); + assert!((rms - 0.0625).abs() < 0.01, "rms {rms}"); +} + +/// An AVI whose video FilmCraft can't decode imports, and says why it shows no picture (as an +/// unsupported stream in any other container does), rather than failing as a broken file. +#[test] +fn avi_with_an_unsupported_codec_says_so() { + let Some(p) = ffmpeg_fixture("testsrc2_xvid.avi", &["-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=1", "-c:v", "mpeg4", "-vtag", "XVID"]) + else { + return; + }; + let f = probe(&p).expect("a valid AVI imports"); + assert_eq!((f.kind, f.width, f.height), (FootageKind::Video, 320, 180)); + let pool = MediaPool::new(); + if pool.frame(ItemId(1), &f, Tick::ZERO).is_some() { + // FilmCraft decodes MPEG-4 Part 2 now: nothing to report + assert_eq!(pool.problem(&f), None); + return; + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("MPEG-4 Part 2") && why.contains("isn't supported"), "{why}"); +} + +/// A file that only looks like an AVI is an import error that says so. +#[test] +fn an_invalid_avi_is_an_import_error() { + let e = probe_bytes("broken.avi", Arc::from(&b"RIFF\x10\0\0\0AVI LIST\0\0\0\0garbage"[..])).expect_err("not an AVI"); + assert!(e.to_string().contains("invalid AVI"), "{e}"); +} + +/// A damaged AVI imports: a truncated recording as far as it goes, and a packet that doesn't +/// decode loses its own frame only, with a reason that says the file is damaged rather than +/// unsupported. +#[test] +fn a_damaged_avi_loses_only_what_is_damaged() { + let Some(p) = mjpeg_avi() else { return }; + let bytes = std::fs::read(&p).expect("read"); + let full = probe(&p).expect("probe"); + let pool = MediaPool::new(); + + let cut: Arc<[u8]> = bytes[..bytes.len() / 3].into(); + let f = probe_bytes("cut.avi", cut.clone()).expect("a truncated AVI imports"); + assert!(f.duration > Tick::ZERO && f.duration < full.duration, "{:?} of {:?}", f.duration, full.duration); + pool.add_bytes("cut.avi", cut); + let last = f.frame_rate.frame_at(f.duration - Tick(1)); + assert!(pool.frame(ItemId(1), &f, t_of(f.frame_rate, last)).is_some(), "its last frame"); + + // Garble the JPEG data of the video chunk of frame 10. + let mut bad = bytes.clone(); + let mut at = bad.windows(4).position(|w| w == b"movi").expect("movi") + 4; + let mut video = 0; + while let Some(head) = bad.get(at..at + 8) { + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + if &head[2..4] == b"dc" { + if video == 10 { + bad[at + 8 + 200..at + 8 + size.min(3000)].iter_mut().enumerate().for_each(|(i, b)| *b = if i % 7 == 0 { 0xFF } else { 0 }); + break; + } + video += 1; + } + at += 8 + size + size % 2; + } + assert_eq!(video, 10, "found frame 10's chunk"); + let f = probe_bytes("bad.avi", bad.clone().into()).expect("a damaged AVI imports"); + pool.add_bytes("bad.avi", bad.into()); + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, 10)).is_none(), "the damaged frame"); + for n in [9, 11, 30] { + assert!(pool.frame(ItemId(2), &f, t_of(f.frame_rate, n)).is_some(), "frame {n}"); + } + let why = pool.problem(&f).expect("a failed frame leaves a reason"); + assert!(why.contains("can't be decoded") && !why.contains("isn't supported"), "{why}"); +} + #[test] fn png_sequence_import() { let Some(first) = png_sequence() else { return }; diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..70a85a718 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1089,6 +1089,11 @@ impl FieldOrder { } } +/// File extensions of the movie containers footage is imported from (FilmCraft reads them). The +/// import and proxy file dialogs, the Media Browser and `effectcraft_media::is_importable` all go +/// by this list. +pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm", "mxf", "avi"]; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AlphaMode { #[default] From e26b4af5c07fe8dd29139c71fe5e0a4848d26a43 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 082/546] Media Browser: list movies by the importer's list (#1701) --- crates/engine/src/media_browser.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b394ad937..775e3db98 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -197,7 +197,6 @@ fn sort_entries(entries: &mut [Entry]) { entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase())); } -const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; @@ -207,7 +206,8 @@ const MODEL: &[&str] = &["gltf", "glb", "obj", "stl", "fbx"]; pub fn kind_of(path: &str) -> Option<&'static str> { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); let e = ext.as_str(); - if VIDEO.contains(&e) { + // (An animated GIF plays as a movie.) + if e == "gif" || effectcraft_project::VIDEO_EXTENSIONS.contains(&e) { Some("video") } else if IMAGE.contains(&e) { Some("image") @@ -420,6 +420,15 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// Every movie type the importer reads lists as video, as an animated GIF does. + #[test] + fn movies_list_as_video() { + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain(&["gif"]) { + assert_eq!(kind_of(&format!("/clips/A.{}", ext.to_uppercase())), Some("video"), "{ext}"); + } + assert_eq!(kind_of("/clips/capture.avi"), Some("video")); + } + #[test] fn virtual_tree_lists_like_a_folder() { let mut t = VirtualTree::default(); From 93a9295125e5487c0b9e99cb904cd07bb340901b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 083/546] File dialogs: offer every movie type the importer reads (#1701) Import and Set Proxy build their filters from the shared movie list, so they offer AVI; the web build's file picker gets the same list. --- crates/ui-egui/src/menus.rs | 19 ++++++++++++++----- crates/ui-egui/tests/ui_menus.rs | 20 ++++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 26a7b66f6..eda7ccf7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1337,6 +1337,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("paths", Ask::Import), @@ -1366,7 +1368,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), _ => return None, }; if params.get(key).is_some() @@ -1377,19 +1379,22 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option>(); // A host whose modal dialogs would run a nested event loop inside this frame (macOS: a // drag over the window then re-entered winit's event handler and aborted, #511) shows the // dialog without blocking; the command continues when the answer arrives. if let Some(spawn) = app.hooks.file_dialog_async.as_ref() { let request = match ask { Ask::Folder => crate::FileDialog::Folder, - Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" }, Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, Ask::Save(default) => crate::FileDialog::save(default), }; let tx = app.dialog_answers.0.clone(); @@ -1409,7 +1414,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - picked_value(key, f(MEDIA)) + picked_value(key, f(&footage(MEDIA))) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1419,6 +1424,10 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { + let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; + f(&footage(more)).first().map(|s| json!(s)) + } Ask::Save(default) => { let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) diff --git a/crates/ui-egui/tests/ui_menus.rs b/crates/ui-egui/tests/ui_menus.rs index ca4c8cabc..ce6b97800 100644 --- a/crates/ui-egui/tests/ui_menus.rs +++ b/crates/ui-egui/tests/ui_menus.rs @@ -205,10 +205,10 @@ fn relink_restores_decoded_pixels_and_invalidates_viewer_content() { std::fs::remove_dir_all(dir).unwrap(); } -/// #767: File ▸ Import ▸ File… offers MXF footage (OP1a / OP-Atom from broadcast cameras and -/// Avid), which the Media Browser lists and the importer reads. +/// #767, #1701: File ▸ Import ▸ File… and the proxy dialog offer every movie type the importer +/// reads (MXF from broadcast cameras and Avid, AVI, …), the list the Media Browser goes by too. #[test] -fn import_dialog_offers_mxf() { +fn import_and_proxy_dialogs_offer_every_movie_type() { use std::cell::RefCell; use std::rc::Rc; @@ -219,11 +219,15 @@ fn import_dialog_offers_mxf() { *seen.borrow_mut() = exts.iter().map(|e| e.to_string()).collect(); Vec::new() })); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "file.import", json!({})).unwrap(); - assert!(result.is_null(), "cancelled: nothing imported"); - let offered = offered.borrow(); - assert!(offered.iter().any(|e| e == "mxf"), "offered {offered:?}"); - assert!(offered.iter().any(|e| e == "mov")); + for (command, still) in [("file.import", "psd"), ("file.setProxy", "png")] { + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), command, json!({})).unwrap(); + assert!(result.is_null(), "{command} cancelled: nothing done"); + let offered = offered.borrow(); + for ext in effectcraft_project::VIDEO_EXTENSIONS.iter().chain([&still]) { + assert!(offered.iter().any(|e| e == ext), "{command} offers {ext}: {offered:?}"); + } + assert!(offered.iter().any(|e| e == "avi"), "{command}: {offered:?}"); + } } /// #1059: opening the demo from the start screen and immediately pressing Spacebar previews From 84d0f567656a0ba1b68da5ccbf804c64d2cdc74f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 11:35:05 -0400 Subject: [PATCH 084/546] Docs: AVI import (#1701) --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9e2249e9b..f6e8913c4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | none | none | Media Browser lists it | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | diff --git a/docs/gaps.md b/docs/gaps.md index a20e779cd..bda5eba76 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest. | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | The Media Browser, the import and proxy dialogs and `media::is_importable` share one movie list since #1701, AVI included (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; AVI since #1701, without DV, MPEG-4 Part 2 or other legacy codecs yet); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eac744b6f..74111b27d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -114,7 +114,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | |---|---:|---:|---:|---:|---| -| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; TGA/HDR/DPX/MXF/AVI import since #745, #756, #767, #1701 (no DV or MPEG-4 Part 2 AVI yet); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | From c7715a3f2d43d79e5d419c29aba736444443dd4d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 085/546] Footage: a proxy in use that can't be decoded says why (#1701) The picture comes from an item's proxy while Use Proxy is on, but the Project panel, the Viewer, project.summary and footage.info asked only about the item's own file: a proxy that couldn't be decoded (an AVI with an unsupported codec, say) left the layer empty with no reason given. They now ask FootageSource::item_problem, which gives the proxy's reason, said to be the proxy's, for footage and compositions alike. --- crates/engine/src/commands/footage_panel.rs | 5 +-- crates/engine/src/commands/query.rs | 8 ++-- crates/engine/src/tests_panels.rs | 44 +++++++++++++++++++ crates/render/src/lib.rs | 12 +++++ crates/ui-egui/src/panels/project.rs | 9 ++-- crates/ui-egui/src/panels/viewer.rs | 11 ++--- crates/ui-egui/tests/ui_unreadable_footage.rs | 39 +++++++++++----- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/commands/footage_panel.rs b/crates/engine/src/commands/footage_panel.rs index 1bd0d2236..7336d0c08 100644 --- a/crates/engine/src/commands/footage_panel.rs +++ b/crates/engine/src/commands/footage_panel.rs @@ -207,10 +207,7 @@ fn info(s: &mut Session, _: &Value) -> Result { let item = s.project.item(v.item); let name = item.map(|i| i.name.clone()).unwrap_or_default(); let frame = fr.frame_at(v.time); - let problem = match item.map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => s.footage.problem(f), - _ => None, - }; + let problem = item.and_then(|i| s.footage.item_problem(i)); Ok( json!({"item": v.item.0, "name": name, "time": v.time.seconds(), "frame": frame, "timecode": timecode(item, fr, frame), "in": v.in_point.map(|t| t.seconds()), "out": v.out_point.map(|t| t.seconds()), "duration": dur.map(|d| d.seconds()), "frameRate": fr.as_f64(), "problem": problem}), ) diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 2ea8048fa..ed5eeffd1 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -46,11 +46,9 @@ fn summary(s: &mut Session, _: &Value) -> Result { o["layers"] = json!(c.layers.len()); o["frameRate"] = json!(c.frame_rate.as_f64()); } - // Footage that shows no picture although its file is there (a codec or profile the - // decoders lack): why, so an agent can tell the user. - if let ItemKind::Footage(f) = &i.kind - && let Some(why) = s.footage.problem(f) - { + // Footage (or the proxy in use) that shows no picture although its file is there (a + // codec or profile the decoders lack): why, so an agent can tell the user. + if let Some(why) = s.footage.item_problem(i) { o["problem"] = json!(why); } o diff --git a/crates/engine/src/tests_panels.rs b/crates/engine/src/tests_panels.rs index b442d7c3a..622088745 100644 --- a/crates/engine/src/tests_panels.rs +++ b/crates/engine/src/tests_panels.rs @@ -672,3 +672,47 @@ fn footage_that_cannot_be_decoded_reports_why() { s.execute("footage.open", json!({"item": item.0})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], it["problem"]); } + +/// A proxy in use that can't be decoded leaves its item without a picture although the item's +/// own file is fine: `project.summary` and `footage.info` give the proxy's reason, said to be the +/// proxy's, for footage and compositions alike, and nothing once Use Proxy is off. +#[test] +fn a_proxy_in_use_that_cannot_be_decoded_reports_why() { + struct BrokenProxies; + impl FootageSource for BrokenProxies { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn problem(&self, f: &Footage) -> Option { + f.path.ends_with(".avi").then(|| format!("{} isn't supported by the built-in decoder", f.codec)) + } + } + let mut s = Session { footage: Arc::new(BrokenProxies), ..Default::default() }; + let comp = s.execute("comp.new", json!({"name": "C", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let still = Footage { kind: FootageKind::Still, path: "a.png".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("Plate", Default::default(), None, ItemKind::Footage(still)); + let proxy = Footage { + kind: FootageKind::Video, + path: "plate.avi".into(), + codec: "MPEG-4 Part 2".into(), + width: 32, + height: 18, + has_video: true, + ..Default::default() + }; + let want = json!("Proxy: MPEG-4 Part 2 isn't supported by the built-in decoder"); + let problem = |s: &mut Session, id: u64| { + let sum = s.execute("project.summary", json!({})).unwrap(); + sum["items"].as_array().unwrap().iter().find(|i| i["id"] == json!(id)).unwrap().get("problem").cloned() + }; + for id in [item.0, comp] { + let p = std::sync::Arc::make_mut(&mut s.project); + p.items.get_mut(&ItemId(id)).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy.clone(), enabled: true })); + assert_eq!(problem(&mut s, id), Some(want.clone()), "item {id}"); + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&ItemId(id)).unwrap().proxy.as_mut().unwrap().enabled = false; + assert_eq!(problem(&mut s, id), None, "item {id}: its own file shows, and is fine"); + } + std::sync::Arc::make_mut(&mut s.project).items.get_mut(&item).unwrap().proxy.as_mut().unwrap().enabled = true; + s.execute("footage.open", json!({"item": item.0})).unwrap(); + assert_eq!(s.execute("footage.info", json!({})).unwrap()["problem"], want); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cfefd02db..b5aada454 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -130,6 +130,18 @@ pub trait FootageSource: Send + Sync { fn problem(&self, _footage: &Footage) -> Option { None } + /// [`Self::problem`] of what `item` shows: its proxy's while Use Proxy shows the proxy + /// instead (the reason then says it is the proxy's), else the footage item's own. A + /// composition has one only through its proxy. + fn item_problem(&self, item: &effectcraft_project::Item) -> Option { + match item.proxy.as_deref().filter(|px| px.enabled && px.footage.has_video) { + Some(px) => self.problem(&px.footage).map(|why| format!("Proxy: {why}")), + None => match &item.kind { + ItemKind::Footage(f) => self.problem(f), + _ => None, + }, + } + } } /// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel` diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 76309a5d0..ab5936765 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -678,12 +678,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } } - // Footage that can't be decoded (no decoder for its codec / profile): a warning marker - // at the end of the name cell, the reason on hover. - let problem = match &it.kind { - ItemKind::Footage(f) => app.session.footage.problem(f), - _ => None, - }; + // Footage (or the proxy in use) that can't be decoded (no decoder for its codec / + // profile): a warning marker at the end of the name cell, the reason on hover. + let problem = app.session.footage.item_problem(it); let name_clip = Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list); if let Some(why) = &problem { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 51bab7fda..a50c30e53 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1155,13 +1155,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.warpBanner", r, &text); } - // Footage that can't be decoded (no decoder for its codec / profile): say why on the layer - // instead of leaving a black picture. + // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder + // for its codec / profile): say why on the layer instead of leaving a black picture. let mut notices = 0; for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let effectcraft_engine::project::LayerSource::Footage { item } = &layer.source else { continue }; - let Some(effectcraft_engine::project::ItemKind::Footage(f)) = app.session.project.item(*item).map(|i| &i.kind) else { continue }; - let Some(why) = app.session.footage.problem(f) else { continue }; + let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { + continue; + }; + let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; if notices >= 3 { break; } diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index 9b48b9531..b1b12d6d2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -1,10 +1,11 @@ -//! Footage whose codec the decoders lack says so: a warning marker in the Project panel and a -//! notice on the layer in the Composition viewer, instead of a black picture with no reason. +//! Footage (or the proxy in use for it) whose codec the decoders lack says so: a warning marker in +//! the Project panel and a notice on the layer in the Composition viewer, instead of a black +//! picture with no reason. use std::sync::Arc; use effectcraft_engine::Session; -use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, Proxy}; use effectcraft_engine::render::FootageSource; use effectcraft_render::Image; use effectcraft_time::Tick; @@ -14,19 +15,19 @@ use serde_json::json; const WHY: &str = "H.264 (High 4:2:2) isn't supported by the built-in decoder: chroma_format_idc 2 (only 4:2:0 is implemented)"; -/// A source that decodes nothing and, for `broken` movies, says why. -struct Stub(bool); +/// A source that decodes nothing and says why for the files `broken` picks. +struct Stub(fn(&Footage) -> bool); impl FootageSource for Stub { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn problem(&self, _: &Footage) -> Option { - self.0.then(|| WHY.to_string()) + fn problem(&self, f: &Footage) -> Option { + (self.0)(f).then(|| WHY.to_string()) } } -fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { +fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'static, EffectcraftApp>, ItemId) { let mut s = Session { footage: Arc::new(Stub(broken)), ..Default::default() }; s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); let footage = Footage { @@ -38,7 +39,11 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { duration: Tick::from_seconds_f64(2.0), ..Default::default() }; - let clip = std::sync::Arc::make_mut(&mut s.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + let p = std::sync::Arc::make_mut(&mut s.project); + let clip = p.add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + if let (Some(item), Some(footage)) = (p.items.get_mut(&clip), proxy) { + item.proxy = Some(Box::new(Proxy { footage, enabled: true })); + } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); @@ -48,7 +53,7 @@ fn harness(broken: bool) -> (Harness<'static, EffectcraftApp>, ItemId) { #[test] fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { - let (h, clip) = harness(true); + let (h, clip) = harness(|_| true, None); let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); assert!(marker.label.contains("4:2:2"), "{marker:?}"); let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); @@ -57,7 +62,19 @@ fn undecodable_footage_is_marked_in_the_project_panel_and_the_viewer() { #[test] fn readable_footage_has_no_notice() { - let (h, clip) = harness(false); + let (h, clip) = harness(|_| false, None); assert!(h.state().auto.find(&format!("project.item.{}.problem", clip.0)).is_none()); assert!(h.state().auto.query("viewer.footageProblem.").is_empty()); } + +/// The clip's own file is fine, but the proxy in use for it can't be decoded: the item and the +/// layer say why, and that it is the proxy. +#[test] +fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { + let proxy = Footage { kind: FootageKind::Video, path: "clip_proxy.avi".into(), width: 32, height: 18, has_video: true, ..Default::default() }; + let (h, clip) = harness(|f| f.path.ends_with(".avi"), Some(proxy)); + let marker = h.state().auto.find(&format!("project.item.{}.problem", clip.0)).expect("a warning on the footage item").clone(); + assert!(marker.label.starts_with("Proxy: ") && marker.label.contains("4:2:2"), "{marker:?}"); + let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); + assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); +} From 0a57bb9fc21a909adb822c017f22a0fd497a840d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 17 Oct 2026 14:02:18 -0400 Subject: [PATCH 086/546] Docs: the AVI variants that don't import yet (#1701) --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f6e8913c4..8ca81f715 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | | WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | | MXF (OP1a / OP-Atom) | R/W | yes (FilmCraft's demuxer: AVC, MPEG-2 incl. XDCAM / D-10, VC-3 / DNxHD, ProRes; PCM audio) | none | partial: import (#767), checked against ffmpeg; no MXF output | -| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | +| AVI | R | yes (FilmCraft's demuxer: AVI 1.0 / OpenDML; Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3 audio) | none | partial: import (#1701), checked against ffmpeg; H.264 needs its parameter sets in the chunks (global headers and copies with an `avcC` record don't decode until storytold/filmcraft#2303), and a damaged video index loses the video (storytold/filmcraft#2302); DV, MPEG-4 Part 2 (DivX / Xvid) and other legacy codecs import but show why they have no picture; no AVI output | | DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | | BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | | Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | From f64cd5ce381e3d7e1863eef2ce3599f3a6e262a0 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sat, 17 Oct 2026 20:44:28 +0000 Subject: [PATCH 087/546] 1010: preserve easing while baking parented Orientation --- crates/engine/src/commands/layer.rs | 10 +++++++++- crates/engine/src/tests_rig3d.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 123a81471..e26f0279b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -818,7 +818,15 @@ fn bake_between( let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); - if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { return; } let em = nearest_euler(euler_zyx(exact.0), blend); diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d867337ac..595d3d7c5 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -325,6 +325,27 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { From aff4b082d7157e426c81c6844df739d9b9f09cbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:23:58 -0700 Subject: [PATCH 088/546] 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 46040928c8892f6d4fc1743869c090d229ab6dd9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 21:56:54 -0400 Subject: [PATCH 089/546] #1615: cap CPU frames in flight during export (EFFECTCRAFT_CPU_INFLIGHT) CPU renders kept one frame per thread in flight with no cap, so heavy comps could be OOM-killed. EFFECTCRAFT_CPU_INFLIGHT=N renders at most N frames at once through the existing capped path; unset keeps today's behaviour. With a GPU cap too, the smaller cap holds for the whole batch. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 68 ++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 8dcee03e4..bd80a2a6c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -39,6 +39,40 @@ pub(crate) fn gpu_inflight(var: Option<&str>) -> usize { } } +/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders +/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working +/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI +/// reads no `memory.*` preferences, so this is how a render there is held to fewer frames. +pub(crate) const CPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_CPU_INFLIGHT"; + +/// CPU frames in flight from the `EFFECTCRAFT_CPU_INFLIGHT` value: `None` (no cap) when unset or +/// blank, a positive whole number, or `None` with a warning when it is 0, negative, not a +/// number or too large. +pub(crate) fn cpu_inflight(var: Option<&str>) -> Option { + let v = var?; + if v.trim().is_empty() { + return None; + } + match v.trim().parse::() { + Ok(n) if n >= 1 => Some(n), + _ => { + log::warn!(target: "effectcraft_export", "{CPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: CPU frames are not capped"); + None + } + } +} + +/// The frames-in-flight cap of a batch, and whether it lapses once the GPU stops for good: +/// a GPU cap alone lapses (the CPU fallback renders in parallel, #1511), a CPU cap never does, +/// and with both the smaller one holds for the whole batch. +pub(crate) fn inflight_cap(gpu: Option, cpu: Option) -> (Option, bool) { + match (gpu, cpu) { + (Some(g), None) => (Some(g), true), + (Some(g), Some(c)) => (Some(g.min(c)), false), + (None, c) => (c, false), + } +} + /// `render` then `encode` over the frames `ks`, results in `ks` order. /// /// Without `cap` every frame renders and encodes in one parallel batch (the CPU path, as @@ -208,10 +242,11 @@ impl<'a> Cx<'a> { } return out; } - // The cap is for the GPU's memory: once it has stopped for good, frames render on the - // CPU in parallel. - let gpu_live = || self.job.accel.is_none_or(|a| a.available()); - map_frames_while(ks, self.gpu_cap(), gpu_live, |k| self.frame(comp, k), f) + // A GPU cap alone is for the GPU's memory: once it has stopped for good, frames render on + // the CPU in parallel. A CPU cap (#1615) holds throughout. + let (cap, lapses) = inflight_cap(self.gpu_cap(), cpu_inflight(std::env::var(CPU_INFLIGHT_ENV).ok().as_deref())); + let live = || !lapses || self.job.accel.is_none_or(|a| a.available()); + map_frames_while(ks, cap, live, |k| self.frame(comp, k), f) } /// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every @@ -646,6 +681,31 @@ mod tests { assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator"); } + #[test] + fn cpu_inflight_is_off_unless_a_positive_number() { + assert_eq!(cpu_inflight(None), None); + assert_eq!(cpu_inflight(Some("2")), Some(2)); + assert_eq!(cpu_inflight(Some(" 4 ")), Some(4)); + for bad in ["0", "", " ", "-2", "three", "2.5", "99999999999999999999999"] { + assert_eq!(cpu_inflight(Some(bad)), None, "{bad:?}"); + } + } + + #[test] + fn cpu_cap_holds_and_combines_with_the_gpu_cap() { + // #1615: CPU renders had no cap; a GPU cap alone still lapses when the GPU stops. + assert_eq!(inflight_cap(None, None), (None, false)); + assert_eq!(inflight_cap(None, Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), None), (Some(1), true)); + assert_eq!(inflight_cap(Some(3), Some(2)), (Some(2), false)); + assert_eq!(inflight_cap(Some(1), Some(4)), (Some(1), false)); + // A CPU cap of 2 on 8 threads renders at most 2 frames at once. + let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap(); + let (out, max) = pool.install(|| run_capped(16, inflight_cap(None, Some(2)).0)); + assert_eq!(out.len(), 16); + assert!(max <= 2, "{max} frames in flight"); + } + #[test] fn gpu_inflight_override_is_a_positive_number_or_the_default() { assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT); From 5939735c0b79cad7ec80a386272f1417d8c42cfd Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 02:25:40 +0000 Subject: [PATCH 090/546] fix(i18n): follow browser language preferences (#1700) --- crates/ui-egui/src/i18n.rs | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 81343f9f8..2ed342a52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,36 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. In the browser, use its ordered language preferences (#1700). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + { + static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + LANGUAGE.get_or_init(|| { + let Some(window) = eframe::web_sys::window() else { return "en" }; + let navigator = window.navigator(); + let locales: Vec = navigator.languages().iter().filter_map(|v| v.as_string()).collect(); + browser_language(locales.iter().map(String::as_str), navigator.language().as_deref()) + }) + } +} + +/// Pick the first supported browser preference, including an explicit English preference. +/// Older browsers may only supply `language`; unknown or absent languages use English. +#[cfg(any(target_arch = "wasm32", test))] +fn browser_language<'a>(mut locales: impl Iterator, fallback: Option<&str>) -> &'static str { + locales + .find_map(|locale| { + let language = supported(Some(locale)); + let english = locale.split(['-', '_']).next().is_some_and(|primary| primary.eq_ignore_ascii_case("en")); + (language != "en" || english).then_some(language) + }) + .unwrap_or_else(|| supported(fallback)) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -90,6 +110,9 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ru" => "ru", + "fr" => "fr", + "de" => "de", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -6887,6 +6910,24 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + #[test] + fn browser_preferences_choose_a_supported_language_in_order() { + for (locales, fallback, expected) in [ + (vec!["es-ES"], None, "es"), + (vec!["ja-JP", "es-ES"], None, "ja"), + (vec!["xx-XX", "es-MX"], None, "es"), + (vec!["en-US", "es-ES"], None, "en"), + (vec!["zh-TW"], None, "zh-hant"), + (vec!["de-DE", "es-ES"], None, "de"), + (vec![], Some("ja-JP"), "ja"), + (vec!["xx-XX"], Some("es-ES"), "es"), + (vec![], None, "en"), + (vec!["xx-XX"], Some("xx-XX"), "en"), + ] { + assert_eq!(browser_language(locales.into_iter(), fallback), expected); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -6918,7 +6959,9 @@ mod tests { (Some("CS_cz.UTF-8"), "cs"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), - (Some("de-DE"), "en"), + (Some("de-DE"), "de"), + (Some("fr-CA"), "fr"), + (Some("ru-RU"), "ru"), (None, "en"), ] { assert_eq!(supported(locale), language, "{locale:?}"); From 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 091/546] 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 4124ec6de7c6edd8e5e381217b26e5a8b84cac10 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sun, 18 Oct 2026 17:38:28 +0200 Subject: [PATCH 092/546] Marker names are drawn in a small outlined box, in theme colours A named marker drew its name as bare text beside the flag. After Effects puts it in a small outlined box. The box uses the theme's field fill, field outline and text tokens, so it follows Dark, Darker and Light instead of a fixed grey. It applies to every marker with a name, composition markers included; the dimmed names of nested-comp markers on a precomp layer's bar are unchanged. Split out of the WAV markers change (#1423), where the review asked for it to be its own decision. Test: ui_marker_names renders the timeline in Dark and in Light, samples the box's fill and outline pixels, and checks they equal that theme's tokens and differ between the themes. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/markers_ui.rs | 7 +++- crates/ui-egui/tests/ui_marker_names.rs | 51 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_marker_names.rs diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 2fff7e1c8..ca9a25967 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -298,7 +298,12 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: } p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE)); if !m.comment.is_empty() { - p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text); + // The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field + // colours, so it follows Dark, Darker and Light. + let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text); + let box_rect = Rect::from_min_size(pos2(x + 6.0, y0), vec2(galley.size().x + 8.0, h.max(galley.size().y + 2.0))); + p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text); } let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1)); let id = egui::Id::new(("marker", r.layer, r.index)); diff --git a/crates/ui-egui/tests/ui_marker_names.rs b/crates/ui-egui/tests/ui_marker_names.rs new file mode 100644 index 000000000..b2eea20b4 --- /dev/null +++ b/crates/ui-egui/tests/ui_marker_names.rs @@ -0,0 +1,51 @@ +//! A marker's name is drawn in a small outlined box beside its flag, in the theme's own field colours (egui_kittest, a real +//! render): the fill and the outline follow Dark and Light, and the two themes differ. `MARKER_NAMES_SNAPSHOT=/abs/dir` +//! also saves the rendered panel for each theme. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::theme::ThemeKind; +use egui_kittest::Harness; +use serde_json::json; + +fn close(got: [u8; 4], want: egui::Color32) -> bool { + got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3) +} + +#[test] +fn the_name_box_follows_the_theme() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap(); + s.execute("markers.set", json!({"new": true, "time": 1.0, "comment": "Verse"})).unwrap(); + + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(4); + let ctx = h.ctx.clone(); + + let mut seen = vec![]; + for (kind, name) in [(ThemeKind::Dark, "dark"), (ThemeKind::Light, "light")] { + h.state_mut().set_theme(&ctx, kind); + h.run_steps(4); + let tokens = h.state().tokens; + assert_eq!(tokens.kind, kind); + // the flag's hit area is 10 wide, centred on the marker; the box starts 6 points right of the centre + let hit = h.state().auto.find("timeline.marker.0").unwrap_or_else(|| panic!("the marker is on screen ({name})")).rect; + let (x, y0) = (hit[0] + hit[2] / 2.0, hit[1] + 1.0); + let h_flag = (hit[3] - 1.0).min(10.0); + let image = h.render().expect("the panel renders"); + if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") { + image.save(std::path::Path::new(&dir).join(format!("marker-name-{name}.png"))).unwrap(); + } + let scale = image.width() as f32 / 1600.0; + let at = |px: f32, py: f32| image.get_pixel((px * scale) as u32, (py * scale) as u32).0; + let mid = y0 + h_flag / 2.0; + // inside the box, left of the text: the field fill; on its left edge: the field outline + let fill = at(x + 6.0 + 2.5, mid); + let edge = at(x + 6.0 + 0.5, mid); + assert!(close(fill, tokens.field_bg), "{name}: box fill {fill:?}, the theme's field_bg is {:?}", tokens.field_bg); + assert!(close(edge, tokens.field_border), "{name}: box outline {edge:?}, the theme's field_border is {:?}", tokens.field_border); + seen.push((fill, edge)); + } + assert_ne!(seen[0], seen[1], "the box looks different in Dark and in Light, so it is not a fixed grey"); +} From bccbc666a235ab4eba2ad0ad0b4c22b88e60bd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:36:16 +0000 Subject: [PATCH 093/546] #1417: WAV markers show on the audio layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WAV's markers were never read, so its audio layer showed none. The importer now reads the file's cue points (`cue ` chunk named by `LIST/adtl` `labl` and `note`, sized by `ltxt`) and Adobe's XMP markers (`_PMX` chunk, `xmpDM:markers` entries as elements or attributes, in frames of the track's `xmpDM:frameRate`). A marker present in both encodings is listed once. The reader is bounds-checked and caps the marker count and XMP size. - `Footage.markers` (optional in the file format) holds them at footage time; a layer made from the footage starts with them. - Layer ▸ Markers ▸ Update Markers From Source also works on footage layers. - Markers from a file have no label colour. Tests: reader unit tests (cue, note, ltxt, both XMP forms, both encodings, truncated and hostile files), a probe test, and an end-to-end test through the fully wired session. Not checked against a WAV written by Adobe's tools. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/engine/src/commands/markers.rs | 15 +- crates/host/tests/wav_markers.rs | 75 ++++++ crates/media/Cargo.toml | 1 + crates/media/src/lib.rs | 1 + crates/media/src/probe.rs | 3 + crates/media/src/wav_markers.rs | 350 ++++++++++++++++++++++++++ crates/project/src/build.rs | 10 +- crates/project/src/lib.rs | 5 + 9 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 crates/host/tests/wav_markers.rs create mode 100644 crates/media/src/wav_markers.rs diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..243837d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1443,6 +1443,7 @@ name = "effectcraft-media" version = "0.12.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/commands/markers.rs index 4c979619e..35ab7123f 100644 --- a/crates/engine/src/commands/markers.rs +++ b/crates/engine/src/commands/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -339,10 +344,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs new file mode 100644 index 000000000..6e3943915 --- /dev/null +++ b/crates/host/tests/wav_markers.rs @@ -0,0 +1,75 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again. + +use effectcraft_project::LayerSource; +use serde_json::json; + +/// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). +fn marker_wav() -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let points = [(1u32, 0u32, "Intro"), (2, 4000, "Middle")]; + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for (id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav()).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..52a375fef 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..18609ecf5 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2793be251..12ca2d207 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -98,8 +98,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); Ok(f) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..f7c1bb84d --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,350 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers and the size of the XMP packet are capped. + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + for m in xmp_markers(xmp, rate) { + // Tools that write both encodings repeat each marker. + if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // Cue id -> (name, length in samples). + let mut names: Vec<(u32, String)> = Vec::new(); + let mut lengths: Vec<(u32, u32)> = Vec::new(); + for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + if let Some(cue_id) = u32_at(body, 0) { + match &id { + b"labl" | b"note" => { + let text = label(body.get(4..).unwrap_or_default()); + // A `labl` is the name; a `note` only when there is none. + let known = names.iter().any(|(c, _)| *c == cue_id); + if &id == b"labl" || !known { + names.retain(|(c, _)| *c != cue_id); + names.push((cue_id, text)); + } + } + b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + let mut out = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); + let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); + out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); + } + out +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..1b2f12c81 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -854,6 +854,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -883,7 +891,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a4bffbe0c..4cdb3ac24 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1172,6 +1172,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -1207,6 +1211,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } From ef60d3db9dde0f9e5fe07fd07f38bed1dfc46d51 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:58:09 -0400 Subject: [PATCH 094/546] 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 095/546] Preview: the Extended Viewer startup test doesn't race the first frame (#947) startup_finishes_when_extended_viewer_frames_are_larger failed on busy machines. The harness clock moves 1/30 s per step however long a frame takes, so a slow first frame could use up the startup's limits in harness time. The test now waits for the first frame in real time before stepping on, as it is about the wait for the frames ahead, and checks the fake device was started and not stopped. --- crates/ui-egui/tests/ui_audio_scrub.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index a9b400662..6b79e9838 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -372,9 +372,28 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() { assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}"); h.state().frames.set_budget(budget); h.state().frames.clear(); - assert!(until_started(&mut h), "the preview left its startup"); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + h.step(); + // The first frame renders before the harness clock moves on: each step is 1/30 s however long + // it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that + // clock, and this test is about the wait for the frames ahead. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + while h.state().playback.starting && std::time::Instant::now() < deadline { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(!h.state().playback.starting, "the preview left its startup"); assert!(h.state().audio.is_some(), "with its sound"); assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame"); + let (started, stopped) = { + let d = fake.0.lock().unwrap(); + (d.0.is_some(), d.1) + }; + assert!(started && !stopped, "the device started and kept playing"); } /// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine; From f52ffb63ceb762638fb866a6074e8faae324b9c2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sun, 18 Oct 2026 12:59:24 -0400 Subject: [PATCH 096/546] Viewer: no proxy notice on a collapsed precomp, which draws its own layers (#1701) --- crates/ui-egui/src/panels/viewer.rs | 28 +++++++++++-------- crates/ui-egui/tests/ui_unreadable_footage.rs | 26 ++++++++++++++++- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 795ac0650..bec2440d6 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -8,8 +8,8 @@ use effectcraft_engine::commands::shape_tool::PaintKind; use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource}; -use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; +use effectcraft_engine::render::{EvalCtx, Renderer}; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; @@ -1194,16 +1194,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Footage (or the proxy in use for footage or a precomp) that can't be decoded (no decoder - // for its codec / profile): say why on the layer instead of leaving a black picture. - let mut notices = 0; - for layer in comp.layers.iter().filter(|l| l.is_active_at(time) && l.switches.video) { - let (effectcraft_engine::project::LayerSource::Footage { item } | effectcraft_engine::project::LayerSource::Comp { item }) = &layer.source else { - continue; - }; - let Some(why) = app.session.project.item(*item).and_then(|i| app.session.footage.item_problem(i)) else { continue }; - if notices >= 3 { - break; - } + // for its codec / profile): say why on the layer instead of leaving a black picture. A + // collapsed precomp draws its own layers, never the comp's proxy. + let problems: Vec<(&Layer, String)> = { + let r = Renderer::new(&snap_project, &*app.session.footage, Default::default()); + comp.layers + .iter() + .filter(|l| l.is_active_at(time) && l.switches.video && r.collapsed(&ectx, l).is_none()) + .filter_map(|layer| { + let (LayerSource::Footage { item } | LayerSource::Comp { item }) = &layer.source else { return None }; + Some((layer, snap_project.item(*item).and_then(|i| app.session.footage.item_problem(i))?)) + }) + .take(3) + .collect() + }; + for (notices, (layer, why)) in problems.into_iter().enumerate() { let (m, _) = l2c(&ectx, layer); let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer); let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0)); @@ -1215,7 +1220,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.rect_stroke(r, 3.0, Stroke::new(1.0, t.warning), StrokeKind::Inside); painter.galley(pos2(r.min.x + 12.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text); - notices += 1; } // Free transform box of a path (double-click a path with the Selection tool). diff --git a/crates/ui-egui/tests/ui_unreadable_footage.rs b/crates/ui-egui/tests/ui_unreadable_footage.rs index b1b12d6d2..63e3a75c2 100644 --- a/crates/ui-egui/tests/ui_unreadable_footage.rs +++ b/crates/ui-egui/tests/ui_unreadable_footage.rs @@ -45,10 +45,14 @@ fn harness(broken: fn(&Footage) -> bool, proxy: Option) -> (Harness<'st item.proxy = Some(Box::new(Proxy { footage, enabled: true })); } s.execute("layer.addItem", json!({"item": clip.0})).unwrap(); + (open(s), clip) +} + +fn open(mut s: Session) -> Harness<'static, EffectcraftApp> { s.execute("project.select", json!({"items": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h.run_steps(4); - (h, clip) + h } #[test] @@ -78,3 +82,23 @@ fn an_undecodable_proxy_in_use_is_marked_as_the_proxy() { let notice = h.state().auto.query("viewer.footageProblem.").first().copied().expect("a notice in the viewer").clone(); assert!(notice.label.contains(&format!("Proxy: {WHY}")), "{notice:?}"); } + +/// A precomp whose proxy can't be decoded: the layer says so while it renders through the +/// proxy, but not with Collapse Transformations on, which draws the precomp's own layers. +#[test] +fn a_collapsed_precomp_has_no_notice_for_its_proxy() { + for collapse in [false, true] { + let mut s = Session { footage: Arc::new(Stub(|f| f.path.ends_with(".avi"))), ..Default::default() }; + s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 36, "duration": 4})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let p = Arc::make_mut(&mut s.project); + let inner = p.items.values_mut().find(|i| i.name == "Inner").unwrap(); + let proxy = Footage { kind: FootageKind::Video, path: "inner_proxy.avi".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + inner.proxy = Some(Box::new(Proxy { footage: proxy, enabled: true })); + let inner = inner.id; + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": "collapse", "value": collapse})).unwrap(); + let h = open(s); + assert_eq!(h.state().auto.query("viewer.footageProblem.").is_empty(), collapse, "collapse {collapse}"); + } +} From 791e6bb5ebff409e5d8196db43fa87f25e836056 Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Mon, 19 Oct 2026 01:59:48 +0530 Subject: [PATCH 097/546] 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 098/546] 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 099/546] 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 100/546] 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 101/546] 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 620d340d1577c6470c0685c1cec6607259f69184 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 102/546] WAV markers: read RF64 files and bound the adtl list Two findings from the review of #1423. RF64 (WAVs over 4 GB): a chunk too big for 32 bits has a size field of 0xFFFFFFFF and its real size in the file's `ds64` chunk. The reader took the field literally, so it stopped at the `data` chunk and never reached the `cue ` and `_PMX` chunks that follow it. It now resolves such sizes through `ds64` (the `data` size, and a table of other chunks, capped at 1,024 entries), with checked arithmetic, and leaves the field as it was when there is no usable `ds64`. The `adtl` list: every `labl` record scanned the growing list of names, which is quadratic in the number of records, and nothing bounded how many were kept. The cue points are now read first, names and lengths are kept only for those ids in hash maps, and at most 40,000 `adtl` sub-chunks are looked at. The duplicate check between the cue and XMP markers uses a set instead of a scan. What a file yields is unchanged for any file within the bounds: a `labl` still wins over a `note`, and the first `ltxt` still gives the length. Tests: an RF64 file whose markers follow a sentinel-sized `data` chunk, sizes taken from the `ds64` table (and not taken when the table has no entry), hostile `ds64` sizes and tables and every truncation of an RF64 file, and a 100,000-record `adtl` list that is read in linear time, with the cap applied. The RF64 tests fail with the previous reader. Co-Authored-By: Claude Sonnet 5.5 --- crates/media/src/wav_markers.rs | 223 ++++++++++++++++++++++++++++---- 1 file changed, 195 insertions(+), 28 deletions(-) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs index f7c1bb84d..ca40824b6 100644 --- a/crates/media/src/wav_markers.rs +++ b/crates/media/src/wav_markers.rs @@ -7,8 +7,13 @@ //! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames //! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). //! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! //! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and -//! the number of markers and the size of the XMP packet are capped. +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; use effectcraft_color::Label; use effectcraft_project::Marker; @@ -18,12 +23,21 @@ use effectcraft_time::Tick; pub const MAX_MARKERS: usize = 10_000; /// Largest XMP packet read (bytes). const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; fn u32_at(b: &[u8], at: usize) -> Option { let s = b.get(at..at.checked_add(4)?)?; Some(u32::from_le_bytes(s.try_into().ok()?)) } +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + /// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. fn label(b: &[u8]) -> String { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); @@ -39,14 +53,55 @@ pub fn is_wav(bytes: &[u8]) -> bool { matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") } +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + /// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); let mut out = Vec::new(); let mut at = 12usize; while let Some(head) = bytes.get(at..at.saturating_add(8)) { - let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; let start = at.saturating_add(8); - let Some(end) = start.checked_add(size as usize) else { break }; + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; let Some(body) = bytes.get(start..end) else { break }; out.push((id, body)); // Chunks are padded to an even size. @@ -64,9 +119,10 @@ pub fn read(bytes: &[u8]) -> Vec { let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); let mut out = cue_markers(&chunks, rate); if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); for m in xmp_markers(xmp, rate) { - // Tools that write both encodings repeat each marker. - if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + if seen.insert((m.comment.clone(), m.time)) { out.push(m); } } @@ -80,43 +136,57 @@ pub fn read(bytes: &[u8]) -> Vec { fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); - // Cue id -> (name, length in samples). - let mut names: Vec<(u32, String)> = Vec::new(); - let mut lengths: Vec<(u32, u32)> = Vec::new(); - for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { let mut at = 4usize; while let Some(head) = list.get(at..at.saturating_add(8)) { let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; let start = at.saturating_add(8); let Some(end) = start.checked_add(size as usize) else { break }; let Some(body) = list.get(start..end) else { break }; - if let Some(cue_id) = u32_at(body, 0) { + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { match &id { - b"labl" | b"note" => { - let text = label(body.get(4..).unwrap_or_default()); - // A `labl` is the name; a `note` only when there is none. - let known = names.iter().any(|(c, _)| *c == cue_id); - if &id == b"labl" || !known { - names.retain(|(c, _)| *c != cue_id); - names.push((cue_id, text)); - } + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); } - b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), _ => {} } } at = end.saturating_add(end & 1); } } - let mut out = Vec::new(); - for i in 0..count { - let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; - let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; - let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); - let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); - out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); - } - out + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() } /// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element @@ -347,4 +417,101 @@ mod tests { let m = read(&wav(&[cue(&[(1, 44100)])])); assert_eq!(m[0].time, Tick::ZERO); } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } } From 0c70ee026b0f960e17ce0fa16d19f59763f1d99c Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 103/546] WAV markers: the host test covers the bound layers parameter and undo/redo The end-to-end test called Update Markers From Source only through the selection. It now also passes the layer as the bound `layers` parameter (what a script or the menu binding supplies), checks the markers by name, and checks that the update is one undo step: undo removes the restored marker again and redo brings it back. Co-Authored-By: Claude Sonnet 5.5 --- crates/host/tests/wav_markers.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs index 6e3943915..6f88b9c9c 100644 --- a/crates/host/tests/wav_markers.rs +++ b/crates/host/tests/wav_markers.rs @@ -1,9 +1,17 @@ //! Markers a WAV file carries, through the fully wired session (media import included): the -//! layer made from the file starts with them, and Update Markers From Source copies them again. +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. +use effectcraft_engine::Session; use effectcraft_project::LayerSource; use serde_json::json; +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + /// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). fn marker_wav() -> Vec { fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { @@ -67,9 +75,18 @@ fn a_wav_layer_starts_with_the_markers_in_its_file() { list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); - assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); - let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); - assert_eq!(list.as_array().unwrap().len(), 2); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); let _ = std::fs::remove_dir_all(&dir); } From 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 104/546] 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 f4fbdc6c250898ea724b4f8f5a4885f70318d070 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:10:56 +0200 Subject: [PATCH 105/546] 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 106/546] 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 107/546] 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 108/546] 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 109/546] 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 110/546] 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 111/546] 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 112/546] 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 113/546] 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 114/546] 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 115/546] 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 116/546] 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 117/546] 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 118/546] 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 119/546] 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 120/546] 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 8b715fc22736a9a2434ebf57dc6813e7861fab6a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:18:58 +0200 Subject: [PATCH 121/546] 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 83c76f266fc6f1a2c445814a41e961ad85f6d624 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 18:49:00 +0200 Subject: [PATCH 122/546] 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 daedbd5ca1f0e727a54a16f7df2889067b4aebed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 19:10:21 +0200 Subject: [PATCH 123/546] 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 124/546] 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 a05d97a0e929c54fa8a3e1e461a11cfa9b8fd8b2 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 20:44:32 +0200 Subject: [PATCH 125/546] 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 126/546] 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 67ccf44fbaa78492bfbd35cc603df4af390c129c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:38:45 +0200 Subject: [PATCH 127/546] 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 598f0d51c176243a0ed98cf08c0887bf9c104247 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 22:49:41 -0400 Subject: [PATCH 128/546] 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 129/546] 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 9313784304fdd8fbdbc6daaad94a67dd86bcf781 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:47:51 -0400 Subject: [PATCH 130/546] 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 131/546] 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 132/546] 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 133/546] 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 134/546] 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 30dc57f790e9e55f461662e305923b0709a79239 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:42:08 -0400 Subject: [PATCH 135/546] 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 136/546] 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 137/546] 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 138/546] 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 05f2daa4caaead4578cdcf0602df7b5d0bf8eb70 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:57:40 -0400 Subject: [PATCH 139/546] 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 140/546] 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 141/546] 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 142/546] 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 143/546] 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 f9d6c0801be17f0ec029e130b546417daa20e387 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:57:52 -0400 Subject: [PATCH 144/546] 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 145/546] 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 cfb762b1143e153ef768335ecaa8f13d3aeb4f50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 13:41:34 -0400 Subject: [PATCH 146/546] 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 434258050ac1642eb19051857be23e86a56d00f6 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:00:20 +0900 Subject: [PATCH 147/546] Text: Chinese Han text uses the installed Chinese face (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS does not install the on-demand PingFang SC (macOS 27 on an English install ships Hiragino Sans GB, Heiti SC, Songti SC and STSong instead), and the Simplified Chinese fallback list named only Windows and Linux families. A Chinese character therefore fell through to the exhaustive search, which picks the first installed face that covers it — Hiragino Sans, the Japanese face — so Chinese text in a composition was drawn with Japanese glyph forms (#453). Japanese kana and kanji were already handled, and a Chinese-only character the Japanese face lacks (e.g. 页) still found a Chinese face, which is why the symptom looked selective. Add the macOS Chinese faces to the fallback lists: Hiragino Sans GB, Heiti SC, Songti SC and STSong for Simplified Chinese, Songti TC for Traditional (Heiti TC was already named). The interface language still chooses between the scripts and an English interface keeps the Japanese-first order for shared Han ideographs, as designed in #312; what changes is that a Chinese-locale request has an installed Chinese family to answer it on macOS. Tests: a text-layer render test draws Japanese and Chinese text and checks glyph coverage (skipped where no CJK face is installed); a layout test checks every CJK glyph comes from a face that has the character, so a missing-glyph box cannot creep back; and the fonts test fails without the list change ("Hiragino Sans GB missing from the Han list"), passes with it, and walks the Han branch's list to prove the first installed Simplified family is a Chinese face, not a Japanese one. Verified with CARGO_BUILD_JOBS=2 RUST_TEST_THREADS=2: effectcraft-text 64 passed; effectcraft-render 233 passed, 1 ignored; cargo clippy -p effectcraft-text -p effectcraft-render --all-targets -- -D warnings clean; cargo fmt --all -- --check clean. A 900x240 render of "骨直真具画 中文测试" with the zh-hans request now uses Hiragino Sans GB and differs from the Japanese render, which still uses Hiragino Sans. --- ROADMAP.md | 8 ++++++- crates/render/src/tests.rs | 21 ++++++++++++++++ crates/text/src/fonts.rs | 48 ++++++++++++++++++++++++++++++++++--- crates/text/src/layout.rs | 22 +++++++++++++++++ docs/gaps.md | 5 ++-- docs/localization-parity.md | 5 ++-- docs/roadmap.md | 5 ++-- docs/target-app-parity.md | 7 +++--- 8 files changed, 108 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..4b2fbfe00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Simplified Chinese Han text in compositions reaches the installed macOS Chinese faces instead of the Japanese face, with regression tests, #453; previous: minor, open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-22: Text: Chinese Han text in compositions now draws with the installed Chinese face + instead of the Japanese one. macOS does not install the on-demand PingFang SC, so the Simplified + Han list names Hiragino Sans GB, Heiti SC, Songti SC and STSong (Heiti TC and Songti TC for + Traditional) before the fallback search can reach a Japanese face (#453). Japanese kana and kanji + are unchanged; a text-layer render test and a macOS fallback test cover both languages. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all @@ -154,6 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese Han text in compositions uses the installed macOS Chinese faces instead of the Japanese face; CJK text-layer and macOS fallback regression tests (#453) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..7f09339fd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -219,6 +219,27 @@ fn text_layer_renders_glyphs() { assert!(covered > 300, "{covered}"); } +/// #453: a composition's Japanese (and Chinese) text layer draws its glyphs, not missing-glyph +/// boxes. Skipped where no CJK face is installed. +#[test] +fn text_layer_renders_cjk_glyphs() { + let cjk = effectcraft_text::fonts::resolve("Inter", "Regular").face; + if !effectcraft_text::fonts::face(effectcraft_text::fonts::fallback_for('日', cjk)).has_char('日') { + eprintln!("no Japanese face installed; skipping"); + return; + } + for text in ["日本語", "中文"] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: text.into(), size: 46.0, ..Default::default() })); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([100.0, 70.0, 0.0]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let covered = img.data.iter().filter(|px| px[3] > 0.5).count(); + assert!(covered > 200, "{text}: {covered} covered pixels"); + } +} + #[test] fn precomp_and_3d_render() { let (mut p, cid, comp) = setup(); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index aeabf1872..5e057811c 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -685,14 +685,28 @@ const JAPANESE_FAMILIES: &[&str] = &[ ]; /// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables /// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. -const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = - &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// `Hiragino Sans GB`, `Heiti SC` and `Songti SC` are the Simplified faces a macOS install without +/// the on-demand PingFang has (macOS 27 on an English install ships them, not PingFang): without +/// them Chinese Han text fell through to the Japanese face and drew Japanese glyph forms (#453). +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = &[ + "Microsoft YaHei", + "SimSun", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "Songti SC", + "STSong", + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", +]; /// The same for Traditional Chinese. The two scripts share almost all of their code points but not /// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Songti TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -1307,6 +1321,34 @@ mod tests { assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } + /// macOS ships no Simplified Chinese PingFang on an English install (macOS 27 has the + /// on-demand PingFang instead): Hiragino Sans GB, Heiti SC and Songti SC are the installed + /// Chinese faces, so the Han list has to name them or Han text falls through to the Japanese + /// face and draws Japanese glyph forms (#453). The system check is skipped where none of them + /// is installed; the list itself is checked everywhere. + #[test] + fn macos_chinese_faces_are_tried_for_han_text() { + for face in ["Hiragino Sans GB", "Heiti SC", "Songti SC"] { + assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case(face)), "{face} missing from the Han list"); + } + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|l| l.eq_ignore_ascii_case("Songti TC")), "Songti TC missing from the Traditional list"); + scan_system(); + let installed: Vec<&str> = ["Hiragino Sans GB", "Heiti SC", "Songti SC"].into_iter().filter(|f| resolve_in(f, "Regular").is_some()).collect(); + if installed.is_empty() { + eprintln!("no macOS Simplified Chinese face installed; skipping the system check"); + return; + } + // Walking the Han branch's own list finds a Chinese face before the Japanese face the + // exhaustive search would have found (Hiragino Sans), and it covers the shared ideographs. + let (family, resolved) = + SIMPLIFIED_CHINESE_FAMILIES.iter().find_map(|f| resolve_in(f, "Regular").map(|r| (*f, r))).expect("an installed Simplified Chinese family"); + assert!(installed.iter().any(|i| i.eq_ignore_ascii_case(family)), "{family} is not a macOS Chinese face"); + assert!(!JAPANESE_FAMILIES.iter().any(|j| j.eq_ignore_ascii_case(family)), "{family} is a Japanese family"); + for c in ['骨', '直', '日'] { + assert!(face(resolved.face).has_char(c), "{family} cannot draw {c}"); + } + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 2e863d1ae..770afb965 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1284,6 +1284,28 @@ mod tests { assert!(l.glyphs.iter().all(|g| g.id != 0)); } + /// CJK text drawn with a bundled Latin family falls back to an installed face that has the + /// glyph, so a composition's Japanese or Chinese text is not drawn as missing-glyph boxes + /// (#453). Skipped where no CJK face is installed, like the font-fallback tests. + #[test] + fn cjk_text_falls_back_to_a_face_with_the_glyph() { + let primary = fonts::resolve("Inter", "Regular").face; + if !fonts::face(fonts::fallback_for('日', primary)).has_char('日') { + eprintln!("no Japanese face installed; skipping"); + return; + } + for text in ["日本語", "中文漢字"] { + let l = layout(text, &st(40.0), &ParagraphStyle::default()); + assert_eq!(l.glyphs.len(), text.chars().count(), "{text}"); + for g in &l.glyphs { + let ch = text.get(g.cluster..).and_then(|rest| rest.chars().next()).unwrap_or('?'); + assert_ne!(g.id, 0, "{ch} has a glyph"); + let f = fonts::face(g.face); + assert!(f.has_char(ch), "{ch} drawn by {} {} which lacks it", f.info.family, f.info.style); + } + } + } + #[test] fn empty_text_has_a_caret() { let l = layout("", &st(40.0), &ParagraphStyle::default()); diff --git a/docs/gaps.md b/docs/gaps.md index fd92c22a4..7c01c96c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (gap 11: the Chinese Han fallback reaches the installed macOS Chinese faces, #453; previous: minor, path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -67,7 +67,7 @@ are grouped by area; the issue tracker has the rest. | 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; UI and GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | | 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | -| 11 | **Text rendering and layout** | Open: #453 CJK text in comps, #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | @@ -335,6 +335,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Gap 11: the Chinese Han fallback reaches the installed macOS Chinese faces, so Chinese text in compositions no longer draws Japanese glyph forms (#453) | | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) | | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..b99ad5884 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed Chinese faces, so Chinese text in compositions draws Chinese glyph forms, #453; previous: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -64,7 +64,7 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language and now reaches the installed Chinese faces (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | @@ -103,6 +103,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese text in compositions follows the fixed Han fallback: installed Chinese faces are tried before the Japanese face (#453) | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/roadmap.md b/docs/roadmap.md index 73fdfd235..e2692fd8b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed Chinese faces, #453; previous: major, new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 Forward-looking detail behind the [ROADMAP](../ROADMAP.md). The work list itself, one gap at a time, is [gaps.md](gaps.md); the numbers are in [target-app-parity.md](target-app-parity.md). @@ -31,7 +31,7 @@ none blocks the workflow. |---|---|---|---| | 1. Set up: new comp, import footage, stills, PSD/AI and audio, arrange and trim layers | yes | `file.import` and image-sequence tests, `ui_drag_drop`, `ui_project_*`; rough: a still imports as a sequence (#431), an MP3 layer can go silent (#324), multi-file drops (#552) | 2–4 | | 2. Animate: keyframes, easing in the Graph Editor, motion paths, parenting | yes | 168 live samples match After Effects 26.3 ([fidelity/](fidelity/README.md)); `ui_keyframes`; rough: Graph Editor jitter and clipped buttons (#456, #457), negative rotation drag (#569) | 3–6 | -| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), CJK text in comps (#453) | 3–6 | +| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), Chinese text in comps follows the Han fallback (#453 fixed) | 3–6 | | 4. Composite: masks, track mattes, blend modes, keying and effects | yes | Compositor and mask tests, 306 effects with CPU/GPU agreement; rough: mask bugs (#510, #513, #527), 32-bpc blend differences (#494), effect bugs (#580 crash, #514) | 6–12 | | 5. Preview with audio (RAM preview, cache) | yes | `ui_ram_preview`, `ui_live_preview`; rough: stutter on cached frames (#595, #304), late audio (#554, #504) | 10–20 | | 6. Save, reopen and deliver: `.ecproj`, auto-save, Render Queue to H.264 / ProRes / sequences | yes | Versioned-schema reopen tests, crash recovery, render-queue and encoder tests; rough: the macOS default output folder is `/`, so a render fails until an output path is chosen (#446), ProRes 4444 colour tag (#340), projects don't relink when moved | 1–3 | @@ -83,5 +83,6 @@ To beta (≈ 75% ready and `.aep` opening): **≈ 350–620 h**. To full parity: | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | The Chinese Han fallback reaches the installed Chinese faces (#453) | | 2026-10-10 | minor | Alpha gate: six core workflows checked; all pass, stage stays alpha | | 2026-10-10 | major | New file: milestones from ROADMAP.md, current focus, next steps with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa4524..a3ddc3bfc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with After Effects -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, catalog and export-format references re-audited against `upstream/main` 470ea7a3; the 10 October re-measure is otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the Chinese Han fallback reaches the installed macOS Chinese faces, so Chinese text in compositions no longer draws Japanese glyph forms, #453; previous: minor, issue, catalog and export-format references re-audited against `upstream/main` 470ea7a3; the 10 October re-measure is otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) The authoritative assessment of how close EffectCraft is to After Effects, and how much work is left. [gaps.md](gaps.md) lists every known shortfall one at a time; the @@ -119,7 +119,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | | Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | -| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | +| Text and typography | 7% | 94% | 50% | 20–35 | Chinese text in comps now reaches the installed Chinese faces in the Han fallback (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 54 built-in presets (text, Transitions, Backgrounds) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | | 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); reflections (#261); camera aperture (#260); #489 and #315 are closed | | Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | Object Matte (26.2) has the effect, the viewer tool (click / box), background tracking, Freeze and the 26.5 disk cache, not yet compared with After Effects; Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | @@ -193,7 +193,7 @@ generative/cloud AI (Object Matte, Firefly), Adobe services and team features, s | Keyframes, Graph Editor, easing, motion paths | 20% | 75% | 168 live samples match After Effects; #456/#457, #553 and #569 were counted here and are now closed | | Expressions: `wiggle`, `loopOut`, linking, sliders | 6% | 80% | `crates/expr` tests; no live expression oracle yet | | Shape layers and masks | 12% | 65% | #510, #513, #527 mask bugs and #303 Mesh Warp were counted here and are now closed; open: #1004, #999, #1042 | -| Text and text animators | 12% | 70% | #316 alignment inverted, #453 CJK (open); #583 font search (closed) | +| Text and text animators | 12% | 70% | #316 alignment inverted; #453 CJK fallback fixed; #583 font search (closed) | | Everyday effects: blurs, glow, fill/tint, curves/levels, keying, drop shadow, displacement | 10% | 70% | All present with GPU paths; unmeasured against After Effects; #514 Apply Color LUT (closed) | | Preview (RAM preview with audio) | 8% | 55% | #595, #304 stutter; #504 late audio (#554 closed) | | Render to H.264 / ProRes / PNG sequences | 6% | 75% | Encoder tests; #340 ProRes 4444 tag (open); #446 default macOS output folder (closed) | @@ -1166,6 +1166,7 @@ top of this page and in items 16–18 above. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Chinese Han text in compositions uses the installed macOS Chinese faces rather than the Japanese face (#453) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open crash list, closed-issue references in the area tables, export formats 12 to 13, localization ≈ 12% to ≈ 18%, `#include`, built-in preset count, 12 October repository counts | | 2026-10-10 | minor | Readiness-by-audience table: hours to ≈ 95% for each of the three numbers; the full number stays an additive weighted sum (unchanged) | | 2026-10-10 | minor | Added mainstream practitioner (≈ 47%) and essentials user (≈ 52%) numbers with weights, discounts and GitHub user evidence; wrote down how the full ready number is built (unchanged at ≈ 45%) | From ad8b6acb6d6f2c8bf356a1c40958247571c1b058 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:24 +0900 Subject: [PATCH 148/546] =?UTF-8?q?i18n(ja):=20panels=20and=20dialogs=20(3?= =?UTF-8?q?00=20rows;=2027%=20=E2=86=92=2039%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog behind the menu bar translated the dialogs and Preferences, but much of the work area was still English: panel tabs, form fields, panel context menus, the tracker and graph controls, text-panel options, the File panel's footers and the credits. Add 300 rows to the Japanese catalog — and the matching Simplified and Traditional Chinese sources the tests keep it in step with (the other languages may lag, an untranslated row falls back to English): - panel tabs (dock.rs / dock_ui.rs titles), the Project panel's columns, footer tooltips and column menu; - form and dialog labels and choice options (the forms the panels open — import, interpret footage, render queue, trackers, text animators…); - panel context menus (timeline layer/property menus, graph editor); - the tracker, graph editor, text-panel, animator and Content-Aware Fill labels; - the Preview panel's transport tooltips, credits table headers and sort options, the composition-settings tabs and the About links; - the engine's text-animator menu labels, added as a data source like the settings pages (they are interface text the panels draw). Strings are wrapped where they are drawn with the existing `tr`/`tr_args` mechanism (the label helpers already ask the catalog, so the data tables need no new ids); the catalog tests keep the three complete languages listing the same sources, reject duplicate or empty rows and keep the placeholder counts. A new test assertion in `japanese_rows_translate_the_panels` pins a sample of this step. The panel catalogs are now 993 Japanese and 995 Chinese rows (the two Chinese catalogs are the complete source list); with the 628-row menu catalogs Japanese is 1,621 of ≈ 4,200 interface strings (39%, up from 27%). Still English: effect parameter names (≈ 1,650 registry names, drawn from the model, not the catalogs), Home-screen literals, tooltips drawn from engine data, engine error messages, and the rows other languages lack. Docs: localization-parity (header, per-language table, overall share, revision history), ROADMAP (language table, progress log, revision history). --- ROADMAP.md | 23 +- crates/ui-egui/src/credits.rs | 8 +- crates/ui-egui/src/dock_ui.rs | 4 +- crates/ui-egui/src/i18n/ui.rs | 913 +++++++++++++++++++++ crates/ui-egui/src/panels/comp_settings.rs | 2 +- crates/ui-egui/src/panels/graph.rs | 1 + crates/ui-egui/src/panels/misc.rs | 3 +- crates/ui-egui/src/panels/project.rs | 12 +- crates/ui-egui/src/panels/tracker.rs | 2 +- docs/localization-parity.md | 29 +- 10 files changed, 962 insertions(+), 35 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1887d7b82..76baa4c7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | -| French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| French | menus only | 15% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 25% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | -| German | menus only | 16% | +| Japanese | partial (menus, most of the panels) | 39% | +| German | menus only | 15% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +Also shipped: Traditional Chinese (partial, 39%), Ukrainian (partial, 27%), Czech (partial, 27%) and Russian (menus only, 15%). No language has had native-speaker review. ## Upcoming @@ -117,6 +117,10 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of + ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all @@ -154,6 +158,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 77357e876..72e5f30ae 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..fb4a2d2e0 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -206,7 +206,7 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::tr(p.title()).to_string()); let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +570,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::tr(p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79c..e27945f70 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -825,6 +825,309 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1565,6 +1868,306 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("Frame Rate", "帧速率"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("3D Renderer", "3D 渲染器"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Search effects and presets", "搜索效果和预设"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "创建摄像机"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -2300,6 +2903,306 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("Frame Rate", "幀率"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("3D Renderer", "3D 轉譯器"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Search effects and presets", "搜尋效果與預設集"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "建立攝影機"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -4447,6 +5350,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock.rs", include_str!("../dock.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), @@ -4504,6 +5408,8 @@ mod tests { ("engine settings pages", include_str!("../../../engine/src/prefs.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/commands/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; /// Call shapes that hand a string literal straight to the user. @@ -4658,6 +5564,13 @@ mod tests { fn japanese_rows_translate_the_panels() { set_current("ja"); assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 2cd19df5d..04c9f20ec 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -259,7 +259,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, crate::i18n::tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 0b372c0a0..50ba90c64 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -245,6 +245,7 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; + let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index cd0ab6897..95e27da93 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -42,7 +42,8 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tr(tip)).clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c4..30d18b722 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -475,7 +475,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -981,7 +987,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::NewComp, "newComp", "Create a new Composition"), ] { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); - let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); if resp.clicked() { @@ -1106,7 +1112,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index bd54625b8..87f59a33a 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -615,7 +615,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = egui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..f35bcc74c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -63,16 +63,16 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | -| Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | -| French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | -| Portuguese (Brazil) | `pt-br` | 1,024 (≈ 26%): menus 624 rows, panels 400 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | +| French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,034 (≈ 27%): menus 624 rows, panels 410 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | -| German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | +| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,14 +80,14 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | -| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | -| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | +| Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 18%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 29%, Japanese 27%, Brazilian Portuguese 26%, Spanish, French, +**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages +(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). @@ -103,6 +103,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | From d2fae0a1449b8ef1ac233fb031f4595694d4d940 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 22 Oct 2026 21:19:20 +0800 Subject: [PATCH 149/546] fix: bound Classic 3D depth-of-field blur and reject non-finite focus distances (#2092) The camera's Set Focus Distance to Layer command compared only 'd <= 0.0', which NaN passes, and two finite but extreme positions can overflow their difference to infinity; a stored non-finite focus distance serializes as null and leaves the project unsavable. Reject it, fall back to the zoom plane in the camera evaluation, and count a non-finite focus or depth as in focus. The Classic 3D blur radius itself was unbounded: a 0%-scaled or very distant layer scales it by up to 1e9, and the padded blur buffer sized from it aborts the process. Cap each plane's radius to a padded buffer that fits the renderer's 16 Mpx layer budget, don't blur planes whose pixels cover under a thousandth of a comp pixel, saturate the padded sizes, and refuse padded buffers past the project's maximum layer size. Regression tests for each guard. --- crates/engine/src/commands/focus.rs | 6 + crates/engine/src/tests_tail.rs | 64 +++++++++++ crates/gpu/src/bokeh.rs | 5 +- crates/raster/src/lib.rs | 6 +- crates/render/src/three_d/camera.rs | 40 ++++--- crates/render/src/three_d/compose.rs | 80 +++++++++++--- crates/render/src/three_d/tests.rs | 157 ++++++++++++++++++++++++++- 7 files changed, 320 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/commands/focus.rs b/crates/engine/src/commands/focus.rs index 013672a38..356ce1a07 100644 --- a/crates/engine/src/commands/focus.rs +++ b/crates/engine/src/commands/focus.rs @@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result { let c = "camera.setFocusToLayer"; let (cid, cam, target) = refs(s, p, c, true)?; let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?; + // Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no + // longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is + // false for NaN. + if !d.is_finite() { + return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)")); + } if d <= 0.0 { return Err(bad(c, "the layer is behind the camera")); } diff --git a/crates/engine/src/tests_tail.rs b/crates/engine/src/tests_tail.rs index 2710365eb..90cfd1bfc 100644 --- a/crates/engine/src/tests_tail.rs +++ b/crates/engine/src/tests_tail.rs @@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() { assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok()); } +/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean +/// errors, and a depth-of-field frame renders right after the command. +#[test] +fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + // Behind the camera (which now sits at z = −500 looking towards +z). + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("behind the camera"), "{e}"); + // Exactly at the camera: depth 0, still a clean error. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap(); + assert!(s.execute("camera.setFocusToLayer", json!({})).is_err()); + // In front: the command works, and the depth-of-field frame renders with finite pixels. + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap(); + let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap(); + assert!((d - 900.0).abs() < 1e-6, "{d}"); + // An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover + // ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap(); + let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap(); + let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap(); + s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer"); + // The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs. + s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap(); + let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default()); + assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred"); +} + +/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number +/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so +/// storing it would leave a project that can't be saved. +#[test] +fn camera_set_focus_to_layer_rejects_non_finite_depth() { + let mut s = comp(400, 300); + let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap(); + s.execute("layer.select", json!({"layers": [cam, t]})).unwrap(); + let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap(); + assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}"); + // The project still round-trips through serde (a stored infinity would come back as null). + let back = roundtrip(&s); + let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64(); + assert!((f - before).abs() < 1e-9, "serde focus distance: {f}"); +} + /// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches. #[test] fn numeric_string_resolves_as_id() { diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index ac07448c9..9150fa6b4 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params}; /// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image. pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option { - let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad); - if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { + // Saturating, so an oversized padding is refused by `fits` below instead of wrapping around. + let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2))); + if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() { return None; } let padded = e.image(w, h); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index f17602829..c01b3d3c2 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -138,9 +138,11 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// Copy with `pad` transparent pixels on every side. Sizes saturate rather than wrap around, + /// but a saturated size can't be allocated: callers bound `pad`. pub fn padded(&self, pad: u32) -> Image { - let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad); + let (w, h) = (self.width.saturating_add(pad.saturating_mul(2)), self.height.saturating_add(pad.saturating_mul(2))); + let mut out = Image::new(w, h); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 5e1f983b2..bfdc4fb7b 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -37,7 +37,8 @@ pub struct Dof { impl Dof { /// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`. pub fn coc(&self, z: f64) -> f64 { - if z <= NEAR || self.focus <= 0.0 { + // No defocus can be measured against a non-finite focus or depth, so it counts as in focus. + if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 { return 0.0; } self.aperture * (z - self.focus).abs() / z * self.blur_level @@ -313,22 +314,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState { let (eye, fwd, down) = layer_frame(ctx, cam); let opts = cam.props.sub("cameraOptions"); let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3); - let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof { - focus: ctx.f(cam, g, "focusDistance", zoom), - aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), - blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, - iris: super::bokeh::Iris { - sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), - rotation: ctx.f(cam, g, "irisRotation", 0.0), - roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, - aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), - fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, - }, - highlight: super::bokeh::Highlight { - gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, - threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, - saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, - }, + let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| { + // A non-finite Focus Distance would make every blur radius non-finite; use the value the + // property defaults to (the zoom plane) instead. + let focus = ctx.f(cam, g, "focusDistance", zoom); + Dof { + focus: if focus.is_finite() { focus } else { zoom }, + aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0), + blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0, + iris: super::bokeh::Iris { + sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")), + rotation: ctx.f(cam, g, "irisRotation", 0.0), + roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0, + aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01), + fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0, + }, + highlight: super::bokeh::Highlight { + gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0, + threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0, + saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0, + }, + } }); CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 6cf87e70e..416eada08 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use effectcraft_color::{BlendMode, blend_pixel}; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec3}; -use effectcraft_project::{Layer, LightKind, MatteKind}; +use effectcraft_project::{Layer, LightKind, MatteKind, limits}; use effectcraft_raster::{Image, Px, hash_noise}; use effectcraft_time::Tick; use rayon::prelude::*; @@ -290,12 +290,43 @@ fn prepare_with<'a>( } /// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from -/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there. -fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec { - dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at +/// most `cap` ([`dof_radius_cap`]). +fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec { + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap) } -fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { +/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to +/// ([`crate::Renderer::raster_scale`]), so a blur never needs more memory than a large layer; +/// twice the buffer when that is larger, so a big buffer can still be blurred. +const DOF_PAD_BUDGET: f64 = 16.0e6; + +/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no +/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer +/// pixels grows without bound as it shrinks: blurring it would cost the most for the least. +const DOF_MIN_SCREEN_SCALE: f64 = 1e-3; + +/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just +/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far +/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far +/// below it and is never clamped. +pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 { + let (w, h) = (w as f64, h as f64); + let budget = DOF_PAD_BUDGET.max(2.0 * w * h); + // (w + 2p)·(h + 2p) = budget, solved for the padding p. + let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0; + ((pad - 2.0) / iris_stretch(dof)).max(0.0) +} + +pub(super) fn dof_radii_at( + cam: &CameraState, + dof: &super::camera::Dof, + world: Mat4, + (w, h): (usize, usize), + offset: [f64; 2], + scale: f64, + cap: f64, +) -> Vec { let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); @@ -309,31 +340,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h) if z <= super::camera::NEAR { continue; } - // coc · ½ / (zoom / z · sx) · scale - *o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32; + // coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius, + // so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp. + let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s; + *o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 }; } }); out } +/// How much the iris' aspect ratio stretches a blur radius along its longer axis. +fn iris_stretch(dof: &super::camera::Dof) -> f64 { + let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt(); + a.max(1.0 / a) +} + /// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`. fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 { - let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt()); - (rmax * a).ceil() as u32 + 2 + ((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2) +} + +/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side: +/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side. +fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> { + let grow = |n: u32| n.saturating_add(pad.saturating_mul(2)); + let (w, h) = (grow(img.width), grow(img.height)); + (w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h)) } /// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded /// buffer's layer-space bounds. `None` when nothing blurs. fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> { - let radii = dof_radii(cam, dof, world, buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return None; } let pad = dof_pad(dof, rmax); - let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize); + let (wp, hp) = padded_size(&buf.img, pad)?; + let (w, h) = (wp as usize, hp as usize); let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64]; - let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale); + let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap); let levels = super::bokeh::blur_levels(&radius)?; let s = buf.scale.max(1e-9); let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s]; @@ -342,16 +390,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) /// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth. fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc) -> Arc { - let radii = dof_radii(cam, dof, world, &buf); + let cap = dof_radius_cap(dof, buf.img.width, buf.img.height); + let radii = dof_radii(cam, dof, world, &buf, cap); let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64; if rmax <= 0.3 { return buf; } let pad = dof_pad(dof, rmax); + if padded_size(&buf.img, pad).is_none() { + return buf; + } let mut padded = buf.img.padded(pad); super::bokeh::boost_highlights(&mut padded, &dof.highlight); let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale }; - let pr = dof_radii(cam, dof, world, &pbuf); + let pr = dof_radii(cam, dof, world, &pbuf, cap); // The (cached, shared) layer buffer stays untouched; the blurred copy is per frame. Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale }) } diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 9f1dbb344..ecaaa4d9a 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -13,10 +13,15 @@ const W: u32 = 200; const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { + setup_sized(W, H) +} + +/// [`setup`] with a `w`×`h` comp. +fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) { let mut p = Project::default(); // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; - let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) } @@ -347,7 +352,7 @@ fn shadow_darkness_and_accepts_off() { // ---------------------------------------------------------------- cameras fn camera(p: &mut Project, comp: &Comp) -> Layer { - build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None) + build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None) } #[test] @@ -423,9 +428,155 @@ fn depth_of_field_blurs_out_of_focus_layers() { assert!(b.get(66, 50)[3] > 0.01); } +/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's +/// default plane (`zoom`) instead. +#[test] +fn dof_falls_back_when_focus_distance_is_not_finite() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]); + let f = cs.dof.unwrap().focus; + assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom()); + // The z = 0 plane is in focus: hard edge, finite pixels. + let img = render(&p, cid); + assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard"); + assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels"); + } +} + +/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal +/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a +/// radius of billions of buffer pixels, whose padded buffer can't be allocated. +#[test] +fn dof_blur_radius_is_bounded_for_degenerate_inputs() { + let scene = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + render(&p, cid) + }; + let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite())); + // A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9). + assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels"); + // Huge but finite Focus Distances (typed in, or a large expression result): the blur is as + // large as the buffer budget allows, never refused (which would render the layer sharp). + // 1e307 overflows the circle of confusion itself to infinity: still the largest blur. + for focus in [1.0e12, 1.0e307] { + let img = scene(focus, 100.0); + assert!(finite(&img), "focus {focus:e}: non-finite pixels"); + assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]); + } +} + +/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred; +/// any other radius stays within the buffer budget's cap. +#[test] +fn dof_radii_skip_degenerate_planes_and_respect_the_cap() { + let radii = |focus: f64, scale: f64| { + let (mut p, cid, comp) = setup(); + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let cp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO); + let cs = super::camera::layer_camera(&ctx, &cp.layers[0]); + let dof = cs.dof.unwrap(); + let cap = super::compose::dof_radius_cap(&dof, 60, 60); + (super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap) + }; + let (r, _) = radii(zoom() + 10.0, 0.0); + assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred"); + let (r, cap) = radii(1.0e12, 100.0); + assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}"); + assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}"); +} + +/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth +/// blur by the same amount on screen: the radius bound must not depend on the layer's scale. +#[test] +fn dof_blur_is_independent_of_layer_scale() { + let ramp = |w: u32, h: u32, scale: f64| { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + // coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom). + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0)); + let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]); + set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0])); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let img = render(&p, cid); + soft_count(&img, (0..400).map(|x| (x, 200))) + }; + let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0)); + assert!(full > 60, "the out-of-focus edge should be soft: {full}"); + assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%"); +} + +/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius +/// (~100 px) is large so that any bound that depends on the resolution would show. +#[test] +fn dof_blur_matches_across_render_resolutions() { + let (mut p, cid, comp) = setup_sized(800, 400); + let zoom = 800.0 * 50.0 / 36.0; + let mut cam = camera(&mut p, &comp); + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom)); + let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]); + push(&mut p, cid, cam); + push(&mut p, cid, s); + let ramp = |scale: f64| { + let img = render_frame(&p, cid, Tick::ZERO, scale); + let (w, h) = (img.width as i64, img.height as i64); + soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale + }; + let (full, half) = (ramp(1.0), ramp(0.5)); + assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px"); + assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px"); +} + +/// `Dof::coc` treats a non-finite focus or depth as in focus. +#[test] +fn dof_coc_is_zero_for_non_finite_inputs() { + let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() }; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}"); + assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}"); + } + assert!(d(400.0).coc(500.0) > 0.0); +} + +/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95. +fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator) -> usize { + pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count() +} + /// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95. fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize { - (0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count() + soft_count(img, (0..img.height as i64).map(|y| (x, y))) } #[test] From 2d360d9a83d5ca01bd655ed26d36945b49816a8a Mon Sep 17 00:00:00 2001 From: midasdf Date: Thu, 22 Oct 2026 23:08:39 +0900 Subject: [PATCH 150/546] Text: the Han fallback test accepts an earlier Chinese family (#2039) The macOS fallback test selected the first installed family from the Simplified list, so a Mac that also has PingFang SC (or Microsoft YaHei / Noto Sans CJK SC) picked that one and then failed an assertion that only accepted the three macOS faces. Accept any family from the list -- every one is Chinese -- and check that each installed macOS face covers the shared ideographs instead. Also exercise fallback_for itself under a zh-CN request, which the direct resolve_in lookup bypassed, and serialise the process-global locale with the layout CJK test so a cached family order cannot leak between them. Tests: effectcraft-text 75 lib + 3 integration passed; effectcraft-render 233 passed, 1 ignored; cargo fmt --all -- --check and cargo clippy -p effectcraft-text --all-targets -- -D warnings clean. --- crates/text/src/fonts.rs | 57 ++++++++++++++++++++++++++++++++++++++- crates/text/src/layout.rs | 2 ++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5e057811c..c5c5cd58d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -744,6 +744,12 @@ const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sa /// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]). static REQUESTED_CJK_LOCALE: RwLock> = RwLock::new(None); +/// Serialises the tests that change the process-global CJK locale with the tests that read it +/// through [`fallback_for`] (the layout tests), which would otherwise cache the other order's +/// families. +#[cfg(test)] +pub(crate) static TEST_CJK_LOCALE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Ask CJK font fallbacks to prefer `tag`'s language for Han ideographs instead of the locale /// environment, so a Chinese interface is drawn with a Chinese face on a machine whose locale is /// Japanese (and the other way round). The interface sets this from its language setting; an empty @@ -989,6 +995,15 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + /// Clears the requested CJK locale when a test that set it ends, panic or not. + struct LocaleReset; + + impl Drop for LocaleReset { + fn drop(&mut self) { + set_cjk_locale(""); + } + } + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -1164,6 +1179,7 @@ mod tests { /// loaded a CJK font first. Skipped where no family from the list is installed. #[test] fn cjk_falls_back_to_a_system_family() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); scan_system(); let inter = resolve("Inter", "Bold").face; let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()); @@ -1284,6 +1300,7 @@ mod tests { #[test] fn the_han_order_follows_the_cjk_locale() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); // Chinese first for any Chinese tag, whether or not it names a script. for tag in ["zh", "zh-CN", "zh_Hans", "ZH-Hant-TW.UTF-8", "zh_MO"] { assert_eq!(han_order_for(tag)[0], FallbackScript::Han, "{tag}"); @@ -1301,6 +1318,7 @@ mod tests { #[test] fn the_chinese_family_list_follows_the_script_the_locale_asks_for() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] { assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}"); } @@ -1342,11 +1360,48 @@ mod tests { // exhaustive search would have found (Hiragino Sans), and it covers the shared ideographs. let (family, resolved) = SIMPLIFIED_CHINESE_FAMILIES.iter().find_map(|f| resolve_in(f, "Regular").map(|r| (*f, r))).expect("an installed Simplified Chinese family"); - assert!(installed.iter().any(|i| i.eq_ignore_ascii_case(family)), "{family} is not a macOS Chinese face"); + // An earlier name can win the walk when this Mac has it as well (`PingFang SC` on an older + // install, or Microsoft YaHei / Noto Sans CJK SC through Office or Homebrew). That is just + // as valid a Chinese fallback as the macOS trio; only the Japanese face would be a + // regression. Each installed macOS face is on the list (checked above) and covers the + // shared ideographs. assert!(!JAPANESE_FAMILIES.iter().any(|j| j.eq_ignore_ascii_case(family)), "{family} is a Japanese family"); for c in ['骨', '直', '日'] { assert!(face(resolved.face).has_char(c), "{family} cannot draw {c}"); } + for name in installed { + let resolved = resolve_in(name, "Regular").expect("installed above"); + for c in ['骨', '直', '日'] { + assert!(face(resolved.face).has_char(c), "{name} cannot draw {c}"); + } + } + } + + /// #453 under a Chinese interface: Han text in a Latin-only family falls back through the Han + /// list, in the locale's order, to an installed Simplified Chinese family; it must not reach the + /// Japanese face the exhaustive search would pick. The direct family lookup above would not + /// catch a `fallback_for` that skipped `han_order`. Skipped where no Simplified family is + /// installed. + #[test] + fn chinese_han_text_falls_back_to_a_simplified_chinese_family() { + let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); + scan_system(); + if !SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()) { + eprintln!("no Simplified Chinese family installed; skipping"); + return; + } + set_cjk_locale("zh-CN"); + let _reset = LocaleReset; + let inter = resolve("Inter", "Regular").face; + for c in ['骨', '直', '日', '真'] { + let f = face(fallback_for(c, inter)); + assert!(f.has_char(c), "{c}: {} {} lacks it", f.info.family, f.info.style); + assert!( + SIMPLIFIED_CHINESE_FAMILIES.iter().any(|n| n.eq_ignore_ascii_case(&f.info.family)), + "{c} → {} is not a Simplified Chinese family", + f.info.family + ); + } } #[test] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 770afb965..47d786241 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1289,6 +1289,8 @@ mod tests { /// (#453). Skipped where no CJK face is installed, like the font-fallback tests. #[test] fn cjk_text_falls_back_to_a_face_with_the_glyph() { + // The CJK locale is process-global; keep a font test from changing it while this lays out. + let _locale = crate::fonts::TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); let primary = fonts::resolve("Inter", "Regular").face; if !fonts::face(fonts::fallback_for('日', primary)).has_char('日') { eprintln!("no Japanese face installed; skipping"); From 5300c9fa87d564c344e41c8a7c5f4339754f7a6d Mon Sep 17 00:00:00 2001 From: midasdf Date: Thu, 22 Oct 2026 23:10:58 +0900 Subject: [PATCH 151/546] i18n(ja): add the missing panel rows and recount localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up to the panel/dialog step: the composition-settings tabs translate "Basic" and "Advanced" through `tr(variable)` and the Preview transport translates "Play/Stop", but none of the three had a catalog row, so they stayed English. Add them to the Japanese, Simplified and Traditional Chinese catalogs and pin them in `japanese_rows_translate_the_panels`. The shared "Type" key means "kind" in the Project column and the Camera Settings dialog, so the Chinese rows read 类型/類型 instead of 文字 ("text"). Keep the graph button bar's automation labels English: translate only the drawn label and tooltip, and translate the stored English strings when the hidden-button menu draws them. Recount the docs from the catalogs: 628 menu rows, 996 Japanese and 998 Chinese panel rows; count Simplified Chinese's 306 effect names in its numerator (46%) and average the ten After Effects languages the table actually names (21%, not the stale 27%). Update the measurement basis, the cross-references in gaps.md and target-app-parity.md, and ROADMAP. --- ROADMAP.md | 15 ++++++----- crates/ui-egui/src/i18n/ui.rs | 18 +++++++++++-- crates/ui-egui/src/panels/graph.rs | 11 ++++---- docs/gaps.md | 4 +-- docs/localization-parity.md | 43 +++++++++++++++--------------- docs/target-app-parity.md | 2 +- 6 files changed, 55 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 76baa4c7d..8b32cc87f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; Japanese 27% → 39%, Simplified Chinese 29% → 46%, overall 18% → 21%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 21% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -85,7 +85,7 @@ UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/lo | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, most of the panels) | 39% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | | Spanish | menus only | 15% | | Hindi | none | 0% | | Arabic | none | 0% | @@ -117,10 +117,11 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log -- 2026-10-22: Localization: the panel/dialog catalogs gain 300 rows in Japanese, Simplified +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, - tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,621 of - ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,623 (39%). + tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + Simplified Chinese is 46% with its 306 effect names. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all @@ -158,7 +159,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs (panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits); language shares recounted (Japanese 27% → 39%, Chinese 29% → 39%, overall 18% → 27%) | +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted (Japanese 27% → 39%, Simplified Chinese 29% → 46% with its effect names, Traditional Chinese 29% → 39%, overall 18% → 21%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index e27945f70..d8b982dbf 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -120,6 +120,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -392,6 +393,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Parameter Name:", "パラメーター名:"), ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセルアスペクト比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -1003,6 +1005,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "縦横比を固定"), ("Frame Rate presets", "フレームレートのプリセット"), ("Background Color", "背景色"), + ("Basic", "基本"), ("Shutter Angle", "シャッター角度"), ("Shutter Phase", "シャッター位相"), ("Samples Per Frame", "フレームあたりのサンプル数"), @@ -1164,6 +1167,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1427,6 +1431,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "参数名称:"), ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1557,7 +1562,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -2046,6 +2051,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "锁定长宽比"), ("Frame Rate presets", "帧速率预设"), ("Background Color", "背景颜色"), + ("Basic", "基本"), ("Shutter Angle", "快门角度"), ("Shutter Phase", "快门相位"), ("Samples Per Frame", "每帧采样数"), @@ -2204,6 +2210,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -2467,6 +2474,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Parameter Name:", "參數名稱:"), ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2597,7 +2605,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -3081,6 +3089,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Lock Aspect Ratio", "鎖定長寬比"), ("Frame Rate presets", "幀率預設集"), ("Background Color", "背景顏色"), + ("Basic", "基本"), ("Shutter Angle", "快門角度"), ("Shutter Phase", "快門相位"), ("Samples Per Frame", "每幀取樣數"), @@ -5571,6 +5580,11 @@ mod tests { assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 50ba90c64..076ad725b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -218,7 +218,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -245,15 +245,16 @@ fn text_button( tip: &str, ) -> bool { let t = app.tokens; - let (label, tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -265,7 +266,7 @@ fn text_button( Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() diff --git a/docs/gaps.md b/docs/gaps.md index fd92c22a4..7507a06d7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 21% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 628 rows each); panels in 6 (ja 996 rows, zh-hans and zh-hant 998 each, uk 524, pt-br 417, cs 518); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f35bcc74c..a03d72e1c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 300 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,621 and Chinese 1,623 of ≈ 4,200 interface strings; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; Japanese 1,624 of ≈ 4,200 interface strings (39%), Simplified and Traditional Chinese 1,626 (Simplified Chinese 1,932 with its 306 effect names, 46%); previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -19,12 +19,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all - fixed entries (624 catalog rows each at 470ea7a3, counted). The generated Effect menu is not part of that fixed tree: its entries + fixed entries (628 catalog rows each at `ad8b6acb`, counted). The generated Effect menu is not part of that fixed tree: its entries and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and - Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified and + Traditional Chinese (998 each), Ukrainian (524), Brazilian Portuguese (417), Czech (518) and + Spanish (513); counted at `ad8b6acb`. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts @@ -53,25 +53,26 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 22 October 2026 at `ad8b6acb` +by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and `i18n/ui.rs`: 628 +menu rows and 998 panel/dialog sources per complete language, 996 in Japanese). The denominator, +**≈ 4,200 user-visible strings**, is a rounded estimate: the 628 fixed menu entries, the 998 +panel/dialog sources, 306 effect names, ≈ 1,650 distinct effect parameter names and ≈ 620 other +unwrapped interface strings (Home screen literals, engine-built tooltips, error and status +messages). It counts distinct strings, not catalog rows; engine messages are only partly counted, +so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,623 (≈ 39%): menus 628 rows, panels 995 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,932 (≈ 46%): menus 628 rows, panels 998, effect names 306 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 628 (≈ 15%) | Menus only (panel catalog in open PR #996) | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 628 (≈ 15%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,045 (≈ 25%): menus 628 rows, panels 417 | Panels and dialogs partly | Latin, fine | no | partial | 6–9 h + review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,621 (≈ 39%): menus 628 rows, panels 993 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,624 (≈ 39%): menus 628 rows, panels 996 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 628 (≈ 15%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -80,20 +81,20 @@ Other languages EffectCraft ships (3): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,623 (≈ 39%): menus 628 rows, panels 995 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,626 (≈ 39%): menus 628 rows, panels 998 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,152 (≈ 27%): menus 628 rows, panels 524 | partial | 6–9 h + review | | Czech | `cs` | 1,146 (≈ 27%): menus 628 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | Russian (`ru`) has a menu catalog (628 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 27%**, the average coverage of After Effects' 10 non-English languages -(Simplified and Traditional Chinese 39%, Japanese 39%, Ukrainian 27%, Czech 27%, Brazilian Portuguese 25%, Spanish, French and German 15%, -German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 39%, Japanese 39%, Brazilian Portuguese 25%, +Spanish, French, German and Russian 15%, Korean and Italian 0%; 209 / 10, was ≈ 18% on 12 October). +This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -103,7 +104,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| -| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese (and no changes to the other catalogs) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 993 Japanese and 995 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; Simplified Chinese reaches 1,932 (46%) with its 306 effect names, and the overall share is 21% | | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ebaa4524..80be7108f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 21% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 6, effects in Simplified Chinese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | From 9e39aea45b5d21e02409bc04ddaf3f050ecd6d9d Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 23 Oct 2026 01:29:35 +0900 Subject: [PATCH 152/546] ROADMAP: the Chinese panel catalogs hold 998 rows, not 1,626 1,626 is the Traditional Chinese total translated-string count; the panel catalogs carry 998 source rows each (996 in Japanese), as docs/localization-parity.md measures. --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8b32cc87f..e3dc2792c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,7 +120,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). - 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, tracker/graph/text-panel controls, File-panel footers and credits. Japanese is 1,624 of - ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 1,626 rows each, and + ≈ 4,200 interface strings (39%); the Chinese panel catalogs hold 998 rows each, and Simplified Chinese is 46% with its 306 effect names. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to From 636f1cf73858c7005ad4eff4404e85931e993193 Mon Sep 17 00:00:00 2001 From: midasdf Date: Fri, 23 Oct 2026 01:37:13 +0900 Subject: [PATCH 153/546] Text: the Japanese fallback test pins ja-JP (#2039) The test checked that Japanese text falls back to an installed Japanese family, but left the process-global CJK locale at the host's: on a zh-CN host with a Chinese face installed, han_order puts the Chinese families first for the shared ideographs and the Japanese-family assertion fails. Set ja-JP under the TEST_CJK_LOCALE lock and clear the request when the test ends, as the zh-CN test beside it does. Tests: effectcraft-text 75 lib + 3 integration passed; cargo fmt --all -- --check passed. --- crates/text/src/fonts.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c5c5cd58d..e41d28098 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1179,7 +1179,11 @@ mod tests { /// loaded a CJK font first. Skipped where no family from the list is installed. #[test] fn cjk_falls_back_to_a_system_family() { + // Japanese text must reach the Japanese list even on a Chinese-language host, where the + // ambient locale puts the Chinese families first for the shared ideographs. let _locale = TEST_CJK_LOCALE.lock().unwrap_or_else(|e| e.into_inner()); + set_cjk_locale("ja-JP"); + let _reset = LocaleReset; scan_system(); let inter = resolve("Inter", "Bold").face; let installed = JAPANESE_FAMILIES.iter().any(|f| resolve_in(f, "Regular").is_some()); From 966e27969107278054b912bd35f31274d944ec1c Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 20:10:51 +0300 Subject: [PATCH 154/546] 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 de4d8251520b4ae8b1148f4ea5286919e89b561e Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 02:18:57 +0900 Subject: [PATCH 155/546] 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 156/546] 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 4e05e2bf25c6c64e336334712ce992dc185501a0 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 02:35:31 +0900 Subject: [PATCH 157/546] i18n(ja): five effect names Adobe's Japanese help corrects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tint is 色合い, Levels is レベル, Exposure is 露出, Stroke is ストローク and Strobe Light is ストロボライト. The list used for cross-checking carried Photoshop's words for three of them. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 912150b18..ffe34048e 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -482,18 +482,18 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"), ("ec.color.curves", "Curves", "トーンカーブ"), ("ec.color.equalize", "Equalize", "平均化(イコライズ)"), - ("ec.color.exposure", "Exposure", "露光量"), + ("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.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.tint", "Tint", "色合い"), ("ec.color.tritone", "Tritone", "トライトーン"), ("ec.color.vibrance", "Vibrance", "自然な彩度"), ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), @@ -557,7 +557,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"), ("ec.generate.radiowaves", "Radio Waves", "電波"), ("ec.generate.scribble", "Scribble", "落書き"), - ("ec.generate.stroke", "Stroke", "線"), + ("ec.generate.stroke", "Stroke", "ストローク"), ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), ("ec.key.colorkey", "Color Key", "カラーキー"), @@ -590,7 +590,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.stylize.posterize", "Posterize", "ポスタリゼーション"), ("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"), ("ec.stylize.scatter", "Scatter", "拡散"), - ("ec.stylize.strobe", "Strobe Light", "ストロボ"), + ("ec.stylize.strobe", "Strobe Light", "ストロボライト"), ("ec.stylize.texturize", "Texturize", "テクスチャ"), ("ec.stylize.threshold", "Threshold", "しきい値"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), From 067d79ce3b99bb55732e5ca383e921997dd02234 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 02:59:33 +0900 Subject: [PATCH 158/546] i18n(ja): keep the effect list's wording for five effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adobe's per-effect pages say 色合い / レベル / 露出 / ストローク / ストロボライト, but its effect list says 色かぶり補正 / レベル補正 / 露光量 / 線 / ストロボ, and the list is what AE's interface follows. The interface wins. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index ffe34048e..912150b18 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -482,18 +482,18 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.colorstabilizer", "Color Stabilizer", "カラースタビライザー"), ("ec.color.curves", "Curves", "トーンカーブ"), ("ec.color.equalize", "Equalize", "平均化(イコライズ)"), - ("ec.color.exposure", "Exposure", "露出"), + ("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.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.tint", "Tint", "色かぶり補正"), ("ec.color.tritone", "Tritone", "トライトーン"), ("ec.color.vibrance", "Vibrance", "自然な彩度"), ("ec.color.videolimiter", "Video Limiter", "ビデオリミッター"), @@ -557,7 +557,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.paintbucket", "Paint Bucket", "塗りつぶし"), ("ec.generate.radiowaves", "Radio Waves", "電波"), ("ec.generate.scribble", "Scribble", "落書き"), - ("ec.generate.stroke", "Stroke", "ストローク"), + ("ec.generate.stroke", "Stroke", "線"), ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), ("ec.key.colorkey", "Color Key", "カラーキー"), @@ -590,7 +590,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.stylize.posterize", "Posterize", "ポスタリゼーション"), ("ec.stylize.roughenedges", "Roughen Edges", "ラフエッジ"), ("ec.stylize.scatter", "Scatter", "拡散"), - ("ec.stylize.strobe", "Strobe Light", "ストロボライト"), + ("ec.stylize.strobe", "Strobe Light", "ストロボ"), ("ec.stylize.texturize", "Texturize", "テクスチャ"), ("ec.stylize.threshold", "Threshold", "しきい値"), ("ec.time.pixelmotionblur", "Pixel Motion Blur", "ピクセルモーションブラー"), From ac9cd8b8d80f50c6b844ce9510d69311ac9ad2ab Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 03:03:52 +0900 Subject: [PATCH 159/546] i18n(ja): the remaining categories the glossary names 285 of 307 ids. The 22 that stay English are the ones no source names: the OCIO set, Screen Key, Unmult, Curl Noise, Puppet, Twirl (Legacy), Stereo 3D Controls, the two face tracking effects, Mocha shape, Paint, and three audio effects. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 137 +++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 912150b18..cb5c18798 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -446,6 +446,24 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// 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.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.delay", "Delay", "ディレイ"), + ("ec.audio.flangechorus", "Flange & Chorus", "フランジ&コーラス"), + ("ec.audio.highlowpass", "High-Low Pass", "ハイパス/ローパス"), + ("ec.audio.modulator", "Modulator", "モジュレーター"), + ("ec.audio.parametriceq", "Parametric EQ", "パラメトリック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", "カメラぶれ除去"), @@ -463,6 +481,19 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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", "自動レベル補正"), @@ -497,6 +528,14 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), ("ec.distort.bulge", "Bulge", "バルジ"), ("ec.distort.ccbender", "CC Bender", "CC Bender"), @@ -560,14 +599,68 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.stroke", "Stroke", "線"), ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "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", "Key Cleaner"), + ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), + ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), + ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), + ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), + ("ec.matte.simplechoker", "Simple Choker", "チョーク"), + ("ec.noise.addgrain", "Add Grain", "グレイン(追加)"), + ("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.pathtext", "Path Text", "パステキスト"), + ("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"), @@ -593,7 +686,51 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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", "タイムワープ"), + ("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.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.rotatesphere", "VR Rotate Sphere", "VR 回転(球)"), + ("ec.vr.sharpen", "VR Sharpen", "VR シャープ"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球体から平面"), ]; /// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. From 03d43fb2f2bed44c3dd11beb078efd2381500f59 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 03:11:03 +0900 Subject: [PATCH 160/546] i18n(ja): two names Adobe translates, and a doc comment that had drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key Cleaner is キークリーナー and Advanced Spill Suppressor is 高度なスピルサプレッション — neither is a brand name, and Adobe's keying page names both. The doc comment claimed the VR and OCIO sets repeat their English names here; the VR rows are Japanese now and the OCIO rows do not exist, so it says what is true instead. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index cb5c18798..2c6cd1369 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -436,9 +436,9 @@ const TRADITIONAL_CHINESE: &[Row] = &[]; /// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this /// one catalog instead of inventing a second mechanism. A translator fills one row per effect, /// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the -/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`, -/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that -/// English name here rather than being left out. +/// `CC` series, `Cryptomatte`, `EXtractoR`, `IDentifier` and `Key Light`) repeat that English name +/// here rather than being left out. An effect with no row draws its English name anyway, which is +/// what the `OCIO` set, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects do. /// /// 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 @@ -599,7 +599,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.stroke", "Stroke", "線"), ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), - ("ec.key.advancedspill", "Advanced Spill Suppressor", "Advanced Spill Suppressor"), + ("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", "カラーキー"), @@ -607,7 +607,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.key.differencematte", "Difference Matte", "異なるマット"), ("ec.key.extract", "Extract", "抽出"), ("ec.key.innerouter", "Inner/Outer Key", "インナー/アウターキー"), - ("ec.key.keycleaner", "Key Cleaner", "Key Cleaner"), + ("ec.key.keycleaner", "Key Cleaner", "キークリーナー"), ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), @@ -744,7 +744,7 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Channel", "チャンネル"), ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), - ("Expression Controls", "式制御"), + ("Expression Controls", "エクスプレッション制御"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), From 8324d93c39e5d5ffc1ccb2952b92f1f0e34f8e96 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 21:33:16 +0300 Subject: [PATCH 161/546] fix(expr): JSON.stringify key lists report cycles; document stack guard coverage (#2124, #2125) An array replacer now copies each source object once per call, so a cycle stays a cycle and the native check throws TypeError, while shared objects still serialize. Documents which threads evaluate expressions, what the 256 KiB guard covers (tested on a 512 KiB thread), that it does not apply on wasm32, and why eval is indirect. --- crates/expr/src/prelude.js | 17 +++++++++++++---- crates/expr/src/runtime.rs | 13 +++++++++---- crates/expr/src/tests/deep_nesting.rs | 26 +++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 3a43a9d10..37b87faab 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -1096,6 +1096,9 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } replace(obj, name, function () { stack(); return apply(f, this, arguments); }); } + // Indirect on purpose: a wrapper can't make a direct eval, so `eval` sees globals and `var`s + // but not `let`/`const` locals of the expression. Keeping direct eval would mean leaving it + // unchecked; expressions practically never need their locals inside eval. var evalJs = eval; eval = function (x) { if (typeof x === 'string') { @@ -1135,10 +1138,12 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } // 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; + // Each source object gets one copy per call, so a cycle in the source is a cycle of copies and + // the native check reports it (TypeError); an object shared without a cycle is still fine. + var stringify = JSON.stringify, MapJs = Map; replace(JSON, 'stringify', function (value, replacer, space) { var fn = typeof replacer === 'function' ? replacer : null; - var keys = null; + var keys = null, copies = new MapJs(); if (Array.isArray(replacer)) { keys = []; for (var i = 0; i < replacer.length; i++) { @@ -1152,8 +1157,12 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } 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]]; + var o = copies.get(v); + if (o === undefined) { + o = {}; + for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + copies.set(v, o); + } v = o; } return v; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 8afa839e5..29e5e4eda 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -235,12 +235,17 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult usize { let marker = 0u8; std::hint::black_box(&marker) as *const u8 as usize diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs index d544d6f80..1bbe7de7e 100644 --- a/crates/expr/src/tests/deep_nesting.rs +++ b/crates/expr/src/tests/deep_nesting.rs @@ -10,9 +10,14 @@ 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 { + eval_on_stack(text, RENDER_STACK) +} + +/// Evaluate `text` on the title's source text on a thread with `size` bytes of stack. +fn eval_on_stack(text: &str, size: usize) -> Result { let text = text.to_string(); std::thread::Builder::new() - .stack_size(RENDER_STACK) + .stack_size(size) .spawn(move || { let f = fx(); f.ev(f.title, "text/sourceText", &text, 0.0) @@ -102,3 +107,22 @@ fn nested_values_still_convert() { assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1"); assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0); } + +/// The main thread runs commands and syntax checks: 1 MiB on Windows. Half of it, for the +/// callers above the evaluation, must still turn the nested-value triggers into errors. +#[test] +fn nested_values_are_an_error_on_half_the_main_thread() { + for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] { + let text = format!("{DEEP_ARRAY} {tail}"); + assert!(eval_on_stack(&text, 512 << 10).is_err(), "{tail}"); + } +} + +#[test] +fn stringify_with_a_key_list_reports_cycles_and_allows_shared_objects() { + let f = fx(); + let cyclic = f.text("var o = {a: 1}; o.self = o; try { JSON.stringify(o, ['a', 'self']) } catch (e) { e.name }", 0.0); + assert_eq!(cyclic, "TypeError"); + let shared = f.text("var x = {a: 1, b: 2}; JSON.stringify({p: x, q: x}, ['p', 'q', 'a'])", 0.0); + assert_eq!(shared, r#"{"p":{"a":1},"q":{"a":1}}"#); +} From a992654e982ea00460da90646cf4436529f16ac9 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 04:50:28 +0900 Subject: [PATCH 162/546] i18n(ja): the OCIO transforms, and two names AE draws with half-width marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read from the Effect menu: OCIO CDL 変換, OCIO 色空間変換, OCIO 表示変換, OCIO ファイル変換 and OCIO Look トランスフォーム. AE draws 稲妻(高度) and 輝度&コントラスト with half-width marks while 平均化(イコライズ) keeps full-width ones, so the punctuation follows the menu per effect. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 2c6cd1369..8b3352b50 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -498,7 +498,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.autocontrast", "Auto Contrast", "自動コントラスト"), ("ec.color.autolevels", "Auto Levels", "自動レベル補正"), ("ec.color.blackwhite", "Black & White", "白黒"), - ("ec.color.brightnesscontrast", "Brightness & Contrast", "輝度&コントラスト"), + ("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"), @@ -520,6 +520,11 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.color.levels", "Levels", "レベル補正"), ("ec.color.levelsic", "Levels (Individual Controls)", "レベル(個々の制御)"), ("ec.color.lumetri", "Lumetri Color", "Lumetri カラー"), + ("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 Look トランスフォーム"), ("ec.color.photofilter", "Photo Filter", "レンズフィルター"), ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS不定マップ"), ("ec.color.selectivecolor", "Selective Color", "特定色域の選択"), @@ -573,7 +578,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.distort.warp", "Warp", "ワープ"), ("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"), ("ec.distort.wavewarp", "Wave Warp", "波形ワープ"), - ("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"), + ("ec.generate.advancedlightning", "Advanced Lightning", "稲妻(高度)"), ("ec.generate.audiospectrum", "Audio Spectrum", "オーディオスペクトラム"), ("ec.generate.audiowaveform", "Audio Waveform", "オーディオウェーブフォーム"), ("ec.generate.beam", "Beam", "レーザー"), From 9ea79ac50bd8b648e198d783f5da285e754ce6a4 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 04:52:35 +0900 Subject: [PATCH 163/546] =?UTF-8?q?i18n(ja):=20the=20category=20is=20?= =?UTF-8?q?=E5=BC=8F=E5=88=B6=E5=BE=A1,=20as=20AE=20draws=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adobe help calls it エクスプレッション制御 in prose; the Effect menu says 式制御. The menu is what a user reads, so it wins. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 8b3352b50..e4301326d 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -749,7 +749,7 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Channel", "チャンネル"), ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), - ("Expression Controls", "エクスプレッション制御"), + ("Expression Controls", "式制御"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), From defaba09b135c48c306719d75d87f53c51f5ed13 Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 04:57:10 +0900 Subject: [PATCH 164/546] i18n(ja): the effect names as AE's Effect menu draws them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu is the authority and it disagreed with this table in three ways: five rows had been translated that AE leaves in English (Key Cleaner, Advanced Spill Suppressor, Unmult, Twirl (Legacy) and mocha shape), eight use a half-width ampersand, and a handful differ only in spacing (Cineon コンバーター, 3D カメラトラッカー, 基本3D). Five effects the table skipped do have names: コンプレッサー, 歪み, ゲート, カールノイズ and VR 平面として投影. Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index e4301326d..ae442a33b 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -455,9 +455,12 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.3d.identifier", "IDentifier", "IDentifier"), ("ec.3d.idmatte", "ID Matte", "IDマット"), ("ec.audio.backwards", "Backwards", "リバース"), - ("ec.audio.basstreble", "Bass & Treble", "バス&トレブル"), + ("ec.audio.basstreble", "Bass & Treble", "バス&トレブル"), + ("ec.audio.compressor", "Compressor", "コンプレッサー"), ("ec.audio.delay", "Delay", "ディレイ"), - ("ec.audio.flangechorus", "Flange & Chorus", "フランジ&コーラス"), + ("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", "パラメトリックEQ"), @@ -539,7 +542,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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.point3d", "3D Point Control", "3D ポイント制御"), ("ec.control.slider", "Slider Control", "スライダー制御"), ("ec.distort.bezierwarp", "Bezier Warp", "ベジェワープ"), ("ec.distort.bulge", "Bulge", "バルジ"), @@ -574,7 +577,8 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.distort.transform", "Transform", "トランスフォーム"), ("ec.distort.turbulentdisplace", "Turbulent Displace", "タービュレントディスプレイス"), ("ec.distort.twirl", "Twirl", "回転"), - ("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(ディテールを保持)"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "Twirl (Legacy)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "アップスケール(詳細を保持)"), ("ec.distort.warp", "Warp", "ワープ"), ("ec.distort.warpstabilizer", "Warp Stabilizer", "ワープスタビライザー"), ("ec.distort.wavewarp", "Wave Warp", "波形ワープ"), @@ -604,7 +608,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.generate.stroke", "Stroke", "線"), ("ec.generate.vegas", "Vegas", "ベガス"), ("ec.generate.writeon", "Write-on", "ブラシアニメーション"), - ("ec.key.advancedspill", "Advanced Spill Suppressor", "高度なスピルサプレッション"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "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", "カラーキー"), @@ -612,7 +616,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.key.differencematte", "Difference Matte", "異なるマット"), ("ec.key.extract", "Extract", "抽出"), ("ec.key.innerouter", "Inner/Outer Key", "インナー/アウターキー"), - ("ec.key.keycleaner", "Key Cleaner", "キークリーナー"), + ("ec.key.keycleaner", "Key Cleaner", "Key Cleaner"), ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), @@ -622,7 +626,8 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), ("ec.matte.simplechoker", "Simple Choker", "チョーク"), ("ec.noise.addgrain", "Add Grain", "グレイン(追加)"), - ("ec.noise.dustscratches", "Dust & Scratches", "ダスト&スクラッチ"), + ("ec.noise.curlnoise", "Curl Noise", "カールノイズ"), + ("ec.noise.dustscratches", "Dust & Scratches", "ダスト&スクラッチ"), ("ec.noise.fractal", "Fractal Noise", "フラクタルノイズ"), ("ec.noise.matchgrain", "Match Grain", "グレイン(マッチ)"), ("ec.noise.median", "Median", "ミディアン"), @@ -637,11 +642,12 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), + ("ec.obsolete.mochashape", "Mocha shape", "mocha shape"), ("ec.obsolete.pathtext", "Path Text", "パステキスト"), ("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.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"), @@ -718,12 +724,12 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.transition.linearwipe", "Linear Wipe", "リニアワイプ"), ("ec.transition.radialwipe", "Radial Wipe", "放射状ワイプ"), ("ec.transition.venetian", "Venetian Blinds", "ブラインド"), - ("ec.utility.applylut", "Apply Color LUT", "カラーLUTを適用"), + ("ec.utility.applylut", "Apply Color LUT", "カラー LUT を適用"), ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), - ("ec.utility.cineon", "Cineon Converter", "Cineonコンバーター"), + ("ec.utility.cineon", "Cineon Converter", "Cineon コンバーター"), ("ec.utility.colorprofileconverter", "Color Profile Converter", "カラープロファイルコンバーター"), ("ec.utility.growbounds", "Grow Bounds", "範囲拡張"), - ("ec.utility.hdrcompander", "HDR Compander", "HDRコンパンダー"), + ("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 色収差"), @@ -733,6 +739,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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 球体から平面"), From 21792fe5365a1ae08a8624132a91262d98ab7c4c Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Fri, 23 Oct 2026 04:58:53 +0900 Subject: [PATCH 165/546] =?UTF-8?q?i18n(ja):=20Keylight=20(1.2)=20and=20?= =?UTF-8?q?=E5=9F=BA=E6=9C=AC3D,=20as=20the=20menu=20draws=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/effects.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index ae442a33b..36d9540d0 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -620,7 +620,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("ec.key.linearcolor", "Linear Color Key", "リニアカラーキー"), ("ec.key.luma", "Luma Key", "ルミナンスキー"), ("ec.key.spill", "Spill Suppressor", "スピルサプレッション"), - ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.keying.keylight", "Key Light", "Keylight (1.2)"), ("ec.matte.mattechoker", "Matte Choker", "マットチョーク"), ("ec.matte.refinehard", "Refine Hard Matte", "ハードマットを調整"), ("ec.matte.refinesoft", "Refine Soft Matte", "ソフトマットを調整"), @@ -638,7 +638,7 @@ const JAPANESE_EFFECTS: &[Row] = &[ ("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.basic3d", "Basic 3D", "基本3D"), ("ec.obsolete.basictext", "Basic Text", "基本テキスト"), ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "ブラー(ガウス)(レガシー)"), ("ec.obsolete.lightning", "Lightning", "稲妻"), From a854e680f76d044c91a760d8fd9ab0be52910bfa Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 23:00:12 +0300 Subject: [PATCH 166/546] test(expr): deep-nesting tests assert the guard's own error (#2124, #2125) Reflect is not an expression global, so Reflect.construct never reached the nesting check; the proxy case asserts Proxy is undefined. --- crates/expr/src/tests/deep_nesting.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs index 1bbe7de7e..ae78c5b20 100644 --- a/crates/expr/src/tests/deep_nesting.rs +++ b/crates/expr/src/tests/deep_nesting.rs @@ -50,10 +50,9 @@ fn eval_and_function_constructors_check_nesting() { 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}"); + assert!(e.contains("nested too deeply"), "{call}: {e}"); } } @@ -77,7 +76,8 @@ fn deep_array_result_is_an_error_or_text_not_a_crash() { // 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}"); + let e = eval_small_stack(&text).expect_err(&text); + assert!(e.contains("nested too deeply"), "{text}: {e}"); } // 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)")); @@ -86,7 +86,8 @@ fn deep_array_result_is_an_error_or_text_not_a_crash() { #[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}"); + let e = eval_small_stack(text).expect_err(text); + assert!(e.contains("Proxy") && e.contains("not defined"), "{text}: {e}"); // 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"); @@ -114,7 +115,8 @@ fn nested_values_still_convert() { fn nested_values_are_an_error_on_half_the_main_thread() { for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] { let text = format!("{DEEP_ARRAY} {tail}"); - assert!(eval_on_stack(&text, 512 << 10).is_err(), "{tail}"); + let e = eval_on_stack(&text, 512 << 10).expect_err(&text); + assert!(e.contains("nested too deeply"), "{tail}: {e}"); } } From 58fbda40b4a8e9662bacab1b95d718c45096bb7b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:30:59 +0000 Subject: [PATCH 167/546] Draw a line to the end point of degenerate SVG arcs Found while testing crates/svg/src/pathdata.rs:228-233 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/pathdata.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/pathdata.rs b/crates/svg/src/pathdata.rs index c8fa7f67f..eb947373f 100644 --- a/crates/svg/src/pathdata.rs +++ b/crates/svg/src/pathdata.rs @@ -226,9 +226,16 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las let sweep = l.flag()?; let e = pt(l)?; let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep }; - match kurbo::Arc::from_svg_arc(&arc) { - Some(a) => a.to_cubic_beziers(0.1, |c1, c2, end| p.curve_to(c1, c2, end)), - None => p.line_to(e), + let mut last_end = None; + if let Some(a) = kurbo::Arc::from_svg_arc(&arc) { + a.to_cubic_beziers(0.1, |c1, c2, end| { + p.curve_to(c1, c2, end); + last_end = Some(end); + }); + } + // Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point. + if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) { + p.line_to(e); } *cur = e; *last_cubic = None; @@ -294,6 +301,18 @@ mod tests { assert!(parse_path_data("M0 0 L10 0 z 9 9").elements().len() <= 4); } + #[test] + fn huge_radius_arc_still_reaches_end_point() { + for d in ["M0 0 A1e20 1e20 0 0 1 10 0 L10 10", "M0 0 A1e20 1e20 0 1 1 10 0 L10 10", "M0 0 a1e20 1e20 0 0 1 10 0 L10 10"] { + let p = parse_path_data(d); + let reaches = p.elements().iter().any(|e| match e { + PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => (q.x - 10.0).abs() < 1e-6 && q.y.abs() < 1e-6, + _ => false, + }); + assert!(reaches, "{d}: {:?}", p.elements()); + } + } + #[test] fn number_lists() { assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]); From d5bbf730460a65679d667519024c41ffbef965f6 Mon Sep 17 00:00:00 2001 From: Altynzhan Karybai Date: Sat, 24 Oct 2026 01:51:07 +0500 Subject: [PATCH 168/546] Fix animation Browse Presets command (#2172) --- crates/ui-egui/src/menus.rs | 8 +++++++- crates/ui-egui/tests/it/ui_menus.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..c9e527ae3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1317,7 +1317,13 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.home_templates = true; Value::Null } - "effect.manage" | "anim.browsePresets" => { + "effect.manage" => { + app.show_panel(PanelKind::EffectsPresets); + Value::Null + } + "anim.browsePresets" => { + app.ui.effects_view.presets = true; + app.ui.effects_open.insert("presets".into()); app.show_panel(PanelKind::EffectsPresets); Value::Null } diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index ca4c8cabc..b06dfaa22 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -46,6 +46,22 @@ fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { h.query_by_label_contains(label).is_some() } +#[test] +fn browse_presets_opens_the_animation_presets_list() { + use effectcraft_ui_egui::dock::PanelKind; + + let mut app = EffectcraftApp::new(Session::default()); + app.ui.effects_view.presets = false; + app.ui.effects_open.clear(); + + let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "anim.browsePresets", json!({})); + + assert!(result.is_ok()); + assert_eq!(app.ui.focused, PanelKind::EffectsPresets); + assert!(app.ui.effects_view.presets); + assert!(app.ui.effects_open.contains("presets")); +} + /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the /// preview behind the open menu. #[test] From 7d7382c14e82dffa44556dd95f77758663752e1f Mon Sep 17 00:00:00 2001 From: Altynzhan Karybai Date: Sat, 24 Oct 2026 02:36:31 +0500 Subject: [PATCH 169/546] Make Browse Presets navigate to visible section (#2172) --- crates/ui-egui/src/menus.rs | 2 ++ crates/ui-egui/tests/it/ui_menus.rs | 31 ++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9e527ae3..284f16b23 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1322,8 +1322,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "anim.browsePresets" => { + app.ui.effects_search.clear(); app.ui.effects_view.presets = true; app.ui.effects_open.insert("presets".into()); + let _ = ctx.data_mut(|data| data.remove_temp::<(f32, f32)>(egui::Id::new("fx-scroll"))); app.show_panel(PanelKind::EffectsPresets); Value::Null } diff --git a/crates/ui-egui/tests/it/ui_menus.rs b/crates/ui-egui/tests/it/ui_menus.rs index b06dfaa22..14e94a400 100644 --- a/crates/ui-egui/tests/it/ui_menus.rs +++ b/crates/ui-egui/tests/it/ui_menus.rs @@ -47,19 +47,32 @@ fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool { } #[test] -fn browse_presets_opens_the_animation_presets_list() { +fn browse_presets_clears_search_and_scrolls_to_the_visible_presets_list() { use effectcraft_ui_egui::dock::PanelKind; - let mut app = EffectcraftApp::new(Session::default()); - app.ui.effects_view.presets = false; - app.ui.effects_open.clear(); + let mut h = harness(); + let ctx = h.ctx.clone(); - let result = effectcraft_ui_egui::menus::invoke(&mut app, &egui::Context::default(), "anim.browsePresets", json!({})); + let managed = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.manage", json!({})); + assert!(managed.is_ok(), "{managed:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); - assert!(result.is_ok()); - assert_eq!(app.ui.focused, PanelKind::EffectsPresets); - assert!(app.ui.effects_view.presets); - assert!(app.ui.effects_open.contains("presets")); + h.state_mut().ui.effects_search = "no matching animation preset".into(); + let scroll_id = egui::Id::new("fx-scroll"); + h.ctx.data_mut(|data| data.insert_temp(scroll_id, (480.0_f32, 960.0_f32))); + assert_eq!(h.ctx.data(|data| data.get_temp::<(f32, f32)>(scroll_id)), Some((480.0, 960.0))); + + let result = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "anim.browsePresets", json!({})); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + assert!(h.state().ui.effects_search.is_empty()); + assert!(h.state().ui.effects_view.presets); + assert!(h.state().ui.effects_open.contains("presets")); + assert!(h.ctx.data(|data| data.get_temp::<(f32, f32)>(scroll_id)).is_none()); + + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets").is_some(), "Animation Presets must be visible after Browse Presets"); } /// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the From d3d23c2f7486624468f6cb8dd9e4269c2937c236 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Sat, 24 Oct 2026 11:26:30 +0800 Subject: [PATCH 170/546] fix: bound Image::padded() at the API so no caller can reintroduce the oversized-pad OOM (#2092 review follow-up) Triage found the pad bound lived only in the DoF callers: Image::padded was pub with a doc comment asking callers to bound pad, so a future caller could reintroduce the exact oversized-buffer OOM this PR fixes. The cap now lives inside padded(): pad is clamped so each output side stays <= MAX_PADDED_SIDE (30 000, mirroring project limits::MAX_SIZE; raster cannot depend on the project crate per layering), overflow unreachable by construction, no panic in any profile. The caller-side padded_size guards stay for reject-on-error UX. Adds raster regression tests: hostile pad (u32::MAX) clamps to cap with source intact at the clamped offset (fail-before: pre-fix sized an oversized buffer); legit pads byte-identical (anti-over-clamp). cargo test -p effectcraft-raster 35/35, -p effectcraft-render 238/238, clippy -D warnings clean, fmt clean. --- crates/raster/src/lib.rs | 46 +++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index c01b3d3c2..3d31da299 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -22,6 +22,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa pub type Px = [f32; 4]; +/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000; +/// raster sits below it in the layering and may not depend on it, so the two must stay equal). +/// A padded side beyond the largest supported comp side has no render use. +pub const MAX_PADDED_SIDE: u32 = 30_000; + #[derive(Clone, Debug, Default, PartialEq)] pub struct Image { pub width: u32, @@ -138,15 +143,21 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. Sizes saturate rather than wrap around, - /// but a saturated size can't be allocated: callers bound `pad`. + /// Copy with `pad` transparent pixels on every side. + /// + /// `pad` is clamped so neither output side exceeds [`MAX_PADDED_SIDE`]: the never-crash bound + /// lives here rather than in each caller. The effective pad is at most + /// `(MAX_PADDED_SIDE - side) / 2`, so `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is + /// unreachable by construction (a side already past the cap gets pad 0 — still no panic). + /// Callers that must *reject* rather than clamp an oversized pad pre-check it (see + /// `padded_size` in render's `three_d/compose.rs`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let (w, h) = (self.width.saturating_add(pad.saturating_mul(2)), self.height.saturating_add(pad.saturating_mul(2))); - let mut out = Image::new(w, h); + let eff = pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2); + let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize]; - let o = (y + pad as usize) * ow + pad as usize; + let o = (y + eff as usize) * ow + eff as usize; out.data[o..o + self.width as usize].copy_from_slice(src); } out @@ -356,4 +367,29 @@ mod tests { assert!((0.0..1.0).contains(&n)); } } + + #[test] + fn padded_bounds_hostile_pad_at_the_api() { + // Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an + // oversized buffer here — the very OOM this guard exists to stop, now caught in padded(). + // (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.) + let mut img = Image::new(29_990, 2); + img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(u32::MAX); + // Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12)); + // The source lands intact at the clamped offset, not the requested one. + assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent + } + + #[test] + fn padded_leaves_legit_pads_untouched() { + // Anti-over-clamp: an ordinary pad must round exactly, byte for byte. + let mut img = Image::new(8, 6); + img.set(3, 2, [1.0, 0.0, 0.0, 1.0]); + let out = img.padded(4); + assert_eq!((out.width, out.height), (16, 14)); + assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4) + } } From 34242e8c1b0071882cb02858a3714245617f8516 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Sat, 24 Oct 2026 11:49:53 +0800 Subject: [PATCH 171/546] fix: move Buf offsets by the pad Image::padded actually applies (#2092 review follow-up) Image::padded clamps the pad so each side stays within MAX_PADDED_SIDE, but Buf::pad still added the requested pad to its offset, so a clamped pad left the pixels and the offset disagreeing (a hostile pad of u32::MAX shifted the pixels by 5 and the offset by ~4.3e9). The clamp now lives in one place, Image::effective_pad, used by both padded() and Buf::pad. The padded() doc now states what the clamp guarantees (output within the largest image the engine supports) and that memory budgets belong to callers that pad by a derived amount (DOF_PAD_BUDGET, GPU fits()). Adds an effects regression test: Buf::pad(u32::MAX) moves the offset by the clamped pad and the source pixel stays at its layer position (fail-before: offset off by u32::MAX). cargo test -p effectcraft-effects 457/457, -p effectcraft-raster 35/35, -p effectcraft-render 238/238; clippy -D warnings and fmt clean on touched crates. --- crates/effects/src/lib.rs | 25 ++++++++++++++++++++----- crates/raster/src/lib.rs | 23 +++++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..ba9314ee6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -286,14 +286,15 @@ impl Buf { self.offset[0] += origin[0] * self.scale; self.offset[1] += origin[1] * self.scale; } - /// Grow by `pad` transparent pixels on every side. + /// Grow by `pad` transparent pixels on every side (as clamped by [`Image::effective_pad`]). pub fn pad(&mut self, pad: u32) { - if pad == 0 { + let eff = self.img.effective_pad(pad); + if eff == 0 { return; } - self.img = self.img.padded(pad); - self.offset[0] += pad as f64; - self.offset[1] += pad as f64; + self.img = self.img.padded(eff); + self.offset[0] += eff as f64; + self.offset[1] += eff as f64; } } @@ -1267,6 +1268,20 @@ mod tests { assert_eq!(out.img.data, img.data); } + #[test] + fn pad_offset_follows_the_clamped_pad() { + // Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset + // must move by the pad actually applied, or the content drifts from its layer position. + let mut img = Image::new(29_990, 2); + img.set(0, 0, [1.0, 0.0, 0.0, 1.0]); + let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 }; + buf.pad(u32::MAX); + // Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5. + assert_eq!(buf.offset, [8.0, 9.0]); + let (x, y) = buf.to_px([-3.0, -4.0]); + assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]); + } + #[test] #[ignore] fn dump_registry() { diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 3d31da299..8a57cafc7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -143,16 +143,23 @@ impl Image { } o } - /// Copy with `pad` transparent pixels on every side. + /// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side + /// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so + /// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side + /// already past the cap gets pad 0). Callers that track where the pixels went (a buffer + /// offset) must move by this, not by the requested pad. + pub fn effective_pad(&self, pad: u32) -> u32 { + pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2) + } + /// Copy with [`Image::effective_pad`] transparent pixels on every side. /// - /// `pad` is clamped so neither output side exceeds [`MAX_PADDED_SIDE`]: the never-crash bound - /// lives here rather than in each caller. The effective pad is at most - /// `(MAX_PADDED_SIDE - side) / 2`, so `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is - /// unreachable by construction (a side already past the cap gets pad 0 — still no panic). - /// Callers that must *reject* rather than clamp an oversized pad pre-check it (see - /// `padded_size` in render's `three_d/compose.rs`); legitimate bounded pads are byte-identical. + /// The clamp keeps the output within the largest image the engine supports (a max-size comp + /// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory + /// budget: callers that pad by a derived, possibly large amount bound the area themselves + /// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an + /// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical. pub fn padded(&self, pad: u32) -> Image { - let eff = pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2); + let eff = self.effective_pad(pad); let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff); let ow = out.width as usize; for y in 0..self.height as usize { From cb46d69e4472b824bc88371550cac76d5fcacdb2 Mon Sep 17 00:00:00 2001 From: Jason Luker <176962611+ZJHeepfixer@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:50:06 -0700 Subject: [PATCH 172/546] Window: start the wgpu surface from eframe's default, one frame in flight instead of two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wgpu_options` built the window's configuration from `WgpuConfiguration::default()`, whose surface is `SurfaceConfig::HIGH_THROUGHPUT` (two frames in flight); eframe's own `NativeOptions::default()` wraps it with `LOW_LATENCY` (one), and replacing `wgpu_options` wholesale silently lost that. On Metal, wgpu-hal sets `maximumDrawableCount = latency + 1`: three drawables instead of two, one more frame between the pointer and the screen on every drag, scrub and slider (8 ms at 120 Hz, 17 ms at 60 Hz). Start from `.with_surface_config(eframe::SurfaceConfig::LOW_LATENCY)`; everything else in `wgpu_options` is unchanged. Measured on an M2 Max (release, `footprint -p`): IOSurface 61 MB → 41 MB (three 21 MB drawables → two). Interface frame rate during 10 s of playback of a 1080p comp whose frames cost 164–279 ms on the GPU stays at 120 fps either way (median 120.1 vs 120.0, p10 116–119.5 vs 117–119.4), so one frame in flight does not starve this comp at 120 Hz. Test: `the_window_surface_is_low_latency` asserts `LOW_LATENCY`, `desired_maximum_frame_latency == Some(1)` and `AutoVsync` for every backend choice, and equality with eframe's default surface. Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft/src/main.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2bc3265dd..833e9a1c4 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -359,9 +359,15 @@ fn main() -> eframe::Result { /// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, /// #198) still opens the app, and the compositor then checks what it needs and falls back to /// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one. +/// +/// The surface is eframe's own default, [`eframe::SurfaceConfig::LOW_LATENCY`]: vsync with one +/// frame in flight, so a drag or a scrub shows one frame sooner. The bare +/// `WgpuConfiguration::default()` is `HIGH_THROUGHPUT` instead (two frames in flight, three +/// drawables on Metal): one more frame of pointer-to-screen lag on every interaction, about 8 ms +/// at 120 Hz and 17 ms at 60 Hz, and a third 15 MB drawable held for the window. fn wgpu_options(backends: Option) -> eframe::WgpuConfiguration { use eframe::egui_wgpu::WgpuSetup; - let mut config = eframe::WgpuConfiguration::default(); + let mut config = eframe::WgpuConfiguration::default().with_surface_config(eframe::SurfaceConfig::LOW_LATENCY); if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { if let Some(b) = backends { setup.instance_descriptor.backends = b; @@ -533,6 +539,19 @@ mod tests { assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); } + /// The window's surface keeps one frame in flight, like eframe's own default, not the two + /// of the bare `WgpuConfiguration::default()` (one extra frame of lag on every drag). + #[test] + fn the_window_surface_is_low_latency() { + for backends in [None, Some(eframe::wgpu::Backends::METAL), Some(eframe::wgpu::Backends::GL)] { + let surface = super::wgpu_options(backends).surface; + assert_eq!(surface, eframe::SurfaceConfig::LOW_LATENCY, "{backends:?}"); + assert_eq!(surface.desired_maximum_frame_latency, Some(1)); + assert_eq!(surface.present_mode, eframe::wgpu::PresentMode::AutoVsync); + } + assert_eq!(super::wgpu_options(None).surface, eframe::NativeOptions::default().wgpu_options.surface, "eframe's default surface"); + } + /// The window's device never asks for more than the adapter offers (#198). #[test] fn device_limits_fit_the_adapter() { From 47f55abb877e8da9695c51477c8431a541ff66f4 Mon Sep 17 00:00:00 2001 From: onlykshitij <30444410+onlykshitij@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:06:38 +0530 Subject: [PATCH 173/546] ScriptUI: fix script-drawn colours showing too bright paint_draw passed a script's 0 to 1 colour values to egui::Rgba, which stores linear light. egui then applied the sRGB curve, so every fillPath, strokePath and drawString colour drew too bright. For example, #2B2B2B drew as RGB 114 and #F28C28 as 249, 196, 110. After Effects treats these values as plain screen colours. Convert them with the existing to_color32 helper instead. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/gradient_editor.rs | 2 +- crates/ui-egui/src/panels/scriptui_view.rs | 31 +++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bb6923025..24733ae9d 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -241,7 +241,7 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn to_color32(c: [f32; 4]) -> Color32 { +pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) } diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index f0a3975fc..a5fbbbc74 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -256,20 +256,22 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } } +/// A draw-list colour (`None` is the theme colour). Script colours are display (sRGB) values +/// in 0..1, as in After Effects, not linear light: `[43/255, 43/255, 43/255, 1]` is RGB 43. +fn draw_color(c: &Option<[f32; 4]>, theme: Color32) -> Color32 { + c.map_or(theme, super::gradient_editor::to_color32) +} + /// Paint an onDraw draw list in `rect` (control coordinates → screen). fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) { let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - let col = |c: &Option<[f32; 4]>, theme: Color32| match c { - Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), - None => theme, - }; let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32); for op in ops { match op { DrawOp::Fill { color, path } => { // Any path shape (concave, self-intersecting, with holes): non-zero winding // triangles, not egui's convex-only polygon fill. - let c = col(color, t.field_bg); + let c = draw_color(color, t.field_bg); let mut mesh = egui::Mesh::default(); for tri in PathSeg::fill_triangles(path, 48, false) { let base = mesh.vertices.len() as u32; @@ -283,14 +285,14 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: & } } DrawOp::Stroke { color, width, path } => { - let s = Stroke::new(*width as f32, col(color, t.text)); + let s = Stroke::new(*width as f32, draw_color(color, t.text)); for (pts, closed) in PathSeg::polylines(path, 48) { let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) }); } } DrawOp::Text { text, color, x, y, size, .. } => { - p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); + p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), draw_color(color, t.text)); } DrawOp::Image { image, x, y, w, h } => { let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services)); @@ -592,3 +594,18 @@ pub fn sync_panels(app: &mut EffectcraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Script colours are sRGB, not linear: `#2B2B2B` drew as RGB 114 when read as linear light. + #[test] + fn draw_colours_are_srgb() { + let v = 43.0 / 255.0; + assert_eq!(draw_color(&Some([v, v, v, 1.0]), Color32::RED), Color32::from_rgb(43, 43, 43)); + let orange = [242.0 / 255.0, 140.0 / 255.0, 40.0 / 255.0, 1.0]; + assert_eq!(draw_color(&Some(orange), Color32::RED), Color32::from_rgb(242, 140, 40)); + assert_eq!(draw_color(&None, Color32::RED), Color32::RED, "no colour is the theme colour"); + } +} From efe7581f9395343af94871d0388eff95dbe5c308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 23:47:45 -0700 Subject: [PATCH 174/546] Scripts: the Startup and Shutdown folders run at launch and quit Scripts in Scripts/Startup and Scripts/Shutdown in the settings folder were never run. The desktop and web apps now run the Startup scripts at launch, before a project opens, and the desktop app runs the Shutdown scripts as it quits, after the project is closed. Each folder runs in alphabetical order with case ignored, and files larger than 16 MiB are not run. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next one still runs. A Startup script that waits in a modal dialog goes on when the dialog closes, while the next scripts run. A Shutdown script's dialogs close as if canceled. With the settings folder on disk, each script runs as its file, so #include, $.fileName and $.evalFile resolve against the script's own folder. The new commands file.runStartupScripts and file.runShutdownScripts return the scripts that ran, those still waiting in a dialog, and those that failed. Like script.run, both are refused in confined automation sessions, in batches and in script steps, which now read one list of script commands (RUNS_SCRIPTS). The list also holds window.scriptPanel and window.restoreScriptPanels, which run ScriptUI panel scripts and were not refused on main either. The apps still call window.restoreScriptPanels at launch. plugins.md, scripting-parity.md, gaps.md, target-app-parity.md and agents.md describe the folders. Each script still runs in its own global scope, so a Startup script's variables are not available to later scripts. --- apps/effectcraft-web/src/app.rs | 2 + apps/effectcraft/src/main.rs | 5 + crates/automation/src/confine.rs | 7 +- crates/automation/src/tests.rs | 12 ++ crates/engine/src/commands/batch.rs | 7 +- crates/engine/src/commands/file_more.rs | 11 +- crates/engine/src/commands/scripts.rs | 99 +++++++++ crates/script/src/lib.rs | 2 + crates/script/src/runtime.rs | 2 +- crates/script/src/tests_startup.rs | 269 ++++++++++++++++++++++++ docs/agents.md | 4 +- docs/gaps.md | 5 +- docs/plugins.md | 6 +- docs/scripting-parity.md | 4 +- docs/target-app-parity.md | 2 +- 15 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 crates/script/src/tests_startup.rs diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 3f462bd2a..d5a767c03 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -238,6 +238,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's // project (the demo on a first visit). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 2bc3265dd..99dc7ce1c 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -243,6 +243,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -455,6 +458,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index f396b3da0..d2f4c70eb 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -28,10 +28,9 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", @@ -135,7 +134,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } if id == "engine.batch" { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..241eae720 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1376,6 +1376,18 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 4bfd2b2b1..aecb42a8d 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -64,7 +65,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 74a86e1d8..f5314e64b 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -480,7 +480,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -505,7 +505,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -521,6 +521,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..396c1c474 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -6,6 +6,9 @@ //! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -22,6 +25,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects @@ -308,6 +323,72 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ : run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -374,6 +455,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 5c1f8cdbb..8472881d9 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -97,6 +97,8 @@ mod tests_include; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_startup; +#[cfg(test)] mod tests_tasks; #[cfg(test)] mod tests_ui; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 192da5740..22e5279c8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/tests_startup.rs b/crates/script/src/tests_startup.rs new file mode 100644 index 000000000..6c13aaf30 --- /dev/null +++ b/crates/script/src/tests_startup.rs @@ -0,0 +1,269 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/docs/agents.md b/docs/agents.md index 5c58d24b7..42b88b066 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -580,7 +580,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c2..a956ed953 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -299,7 +299,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Missing: a global scope that scripts share + for the session (a Startup script's variables and functions are not available to later + scripts), keyboard shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept diff --git a/docs/plugins.md b/docs/plugins.md index a688a89b0..a72eb1561 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -15,7 +15,7 @@ Guide): | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | @@ -38,6 +38,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 7ef7e9080..55bd4a212 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -23,7 +23,7 @@ our own scripts; no real-world script library has been run against both apps. | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `tests_startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | | Keyboard shortcuts for scripts | yes | no | missing | plugins.md | @@ -34,7 +34,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff7cc3ef9..831c5e2d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); 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) | From eff747897df39c3399ff089e63cb5d9d6ca77f16 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:50:22 -0700 Subject: [PATCH 175/546] Fix #341: the close decision runs in App::logic eframe runs App::logic without App::ui while the window is minimized or hidden, and handles a close request then too. The unsaved-changes check, the device-loss close and craft_quit::on_close_request ran from ui, so such a close skipped the prompt and the macOS quit through terminate:. They now run at the end of logic, with the platform hook last, as craft-quit asks. --- crates/ui-egui/src/lib.rs | 15 ++++++++++----- crates/ui-egui/src/panels/unsaved.rs | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90b8c7097..4e0b8b10c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1668,7 +1668,6 @@ impl EffectcraftApp { self.run_script_tasks(&ctx); self.apply_prefs(&ctx); self.handle_events(&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())); @@ -1913,14 +1912,20 @@ impl eframe::App for EffectcraftApp { } self.issue_screenshots(ctx); self.collect_screenshots(ctx); + // eframe runs `logic` without `ui` while the window is minimized or hidden, and handles a + // close then too, so the close decision is made here, with the platform hook last. + if self.gpu_failure_applied == Some(true) { + self.close_after_device_loss(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(ctx); + } else { + panels::unsaved::close_or_quit(self, ctx); + } } 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 e509f889e..9c8c08870 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -391,4 +391,19 @@ mod tests { let macos = cfg!(target_os = "macos"); assert_eq!(close_frame(&mut app, &ctx), (macos, macos), "Don't Save quits"); } + + /// While the window is minimized or hidden, eframe runs `App::logic` without `App::ui` and + /// still handles a close: `logic` asks about a modified project. + #[test] + fn a_close_while_only_logic_runs_asks_about_a_modified_project() { + let (mut app, ctx) = app(); + modified(&mut app); + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + let mut frame = eframe::Frame::_new_kittest(); + let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame)); + let canceled = out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose)); + assert!(canceled, "the close is canceled"); + assert_eq!(app.dialog, Some(Dialog::UnsavedChanges)); + } } From 9514c127cb1b8431c183caaacd18df0d4c9f8835 Mon Sep 17 00:00:00 2001 From: onlykshitij <30444410+onlykshitij@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:33:34 +0530 Subject: [PATCH 176/546] ScriptUI: test that paint_draw paints fill, stroke and text colours as sRGB The first test only called draw_color. This one runs paint_draw on an egui context and checks the colour of each painted shape, so a branch that skips draw_color fails the test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/scriptui_view.rs | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index a5fbbbc74..c9d0ff527 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -608,4 +608,33 @@ mod tests { assert_eq!(draw_color(&Some(orange), Color32::RED), Color32::from_rgb(242, 140, 40)); assert_eq!(draw_color(&None, Color32::RED), Color32::RED, "no colour is the theme colour"); } + + /// fillPath, strokePath and drawString all paint the script's colour as sRGB. + #[test] + fn paint_draw_paints_srgb_colours() { + let v = 43.0 / 255.0; + let color = Some([v, v, v, 1.0]); + let path = vec![PathSeg::R { x: 0.0, y: 0.0, w: 10.0, h: 10.0 }]; + let ops = [ + DrawOp::Fill { color, path: path.clone() }, + DrawOp::Stroke { color, width: 1.0, path }, + DrawOp::Text { text: "Hi".into(), color, x: 0.0, y: 0.0, size: 12.0, style: String::new() }, + ]; + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| { + paint_draw(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 50.0)), &ops, &t, &effectcraft_engine::FsServices); + }); + out.textures_delta.clear(); + let want = Color32::from_rgb(43, 43, 43); + let (mut fill, mut stroke, mut text) = (false, false, false); + for s in out.shapes { + match s.shape { + egui::Shape::Mesh(m) => fill |= m.vertices.iter().all(|v| v.color == want), + egui::Shape::Path(p) => stroke |= matches!(p.stroke.color, egui::epaint::ColorMode::Solid(c) if c == want), + egui::Shape::Text(t) => text |= t.fallback_color == want, + _ => {} + } + } + assert!(fill && stroke && text, "fill {fill}, stroke {stroke}, text {text}"); + } } From decc8b72f96fdb00ea4386efe3203e0991f6d5ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:17:17 -0700 Subject: [PATCH 177/546] Advanced 3D: shadow jitter blends across its lattice cell, so GPU penumbrae match the CPU The PCF tap jitter was keyed on the receiver's 1/8-texel cell in the shadow map. The GPU computes the receiver's map position with last-bit differences from the CPU, so a receiver next to a cell line could take another cell's jitter on one backend, and every tap moved. In a wide penumbra that changed the coverage by several 8-bit levels: gpu_matches_cpu_wide_penumbrae failed on Windows with 15 of 4159 shadowed pixels on an NVIDIA RTX 5080 and 22 on an AMD Radeon iGPU, against a budget of 8. tap_jitter now blends the hashes at the four corners of the cell by the receiver's position in it, in shade.rs and in advanced3d.wgsl. At the lattice points the jitter is the per-cell hash it was before. A CPU test checks that the coverage does not jump at a lattice line. --- crates/gpu/src/shaders/advanced3d.wgsl | 37 ++++++++++++++++++++------ crates/render/src/three_d/adv/shade.rs | 32 +++++++++++++++++----- crates/render/src/three_d/adv/tests.rs | 20 ++++++++++++++ 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..182868423 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { return h; } +fn tap_jitter_at(x: i32, y: i32) -> vec2 { + let h = tap_hash(x, y); + return vec2(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5); +} + +// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice +// cell (cx, cy), blended bilinearly by the receiver's position w in the cell. +fn tap_jitter(cx: i32, cy: i32, w: vec2, i: i32, j: i32) -> vec2 { + let x = cx + i * 7919; + let y = cy + j * 7919; + let c00 = tap_jitter_at(x, y); + let c10 = tap_jitter_at(x + 1, y); + let c01 = tap_jitter_at(x, y + 1); + let c11 = tap_jitter_at(x + 1, y + 1); + let a = c00 + (c10 - c00) * w.x; + let b = c01 + (c11 - c01) * w.x; + return a + (b - a) * w.y; +} + const PCF_MAX_TAPS: i32 = 10; const PLANE_SLOPE_MAX: f32 = 64.0; @@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { // shadows off their casters), each texel compared against the receiver plane's // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid // jittered by an integer hash (bit-identical across backends) of the sub-texel - // cell and tap index, and bilinear compares of the four nearest texel centres. + // lattice and tap index, blended across the receiver's lattice cell, and bilinear + // compares of the four nearest texel centers. // extra.y is the wanted penumbra in output pixels; dividing by the receiver's // texel footprint keeps it in output pixels at any map resolution, and the reach // is at least two texels so hard edges ramp over texel steps instead of showing them. @@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); let step = reach / f32(n - 1); let half = f32(n - 1) * 0.5; - let cx = i32(floor(u * 8.0)); - let cy = i32(floor(v * 8.0)); + let gu = u * 8.0; + let gv = v * 8.0; + let cx = i32(floor(gu)); + let cy = i32(floor(gv)); + let wc = vec2(gu - floor(gu), gv - floor(gv)); var lit = 0.0; for (var j = 0; j < n; j = j + 1) { for (var i = 0; i < n; i = i + 1) { - let h = tap_hash(cx + i * 7919, cy + j * 7919); - let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; - let su = u + ((f32(i) - half) + jx) * step; - let sv = v + ((f32(j) - half) + jy) * step; + let jit = tap_jitter(cx, cy, wc, i, j); + let su = u + ((f32(i) - half) + jit.x) * step; + let sv = v + ((f32(j) - half) + jit.y) * step; // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. let tu = su - 0.5; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index c7c1a40f1..0168a769e 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -217,6 +217,25 @@ fn tap_hash(x: i32, y: i32) -> u32 { h } +/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four +/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the +/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's +/// map position, so a position that differs between backends in its last bits moves the +/// taps by an amount proportional to that difference and does not switch them to another +/// cell's jitter. +fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) { + let corner = |dx: i32, dy: i32| -> (f32, f32) { + let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919))); + (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5) + }; + let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)); + let ax = c00.0 + (c10.0 - c00.0) * wx; + let bx = c01.0 + (c11.0 - c01.0) * wx; + let ay = c00.1 + (c10.1 - c00.1) * wx; + let by = c01.1 + (c11.1 - c01.1) * wx; + (ax + (bx - ax) * wy, ay + (by - ay) * wy) +} + /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). @@ -232,8 +251,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0; /// /// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's -/// texel footprint, each tap jittered by an integer hash of the receiver's -/// sub-texel cell and the tap index, and each tap comparing against the four +/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the +/// receiver's sub-texel lattice and the tap index, blended across the receiver's +/// lattice cell), and each tap comparing against the four /// nearest texel centres with bilinear weights. The jitter detaches the tap grid /// from the texel axes (axis-aligned binary taps are what stair-step a straight /// shadow edge), and keying it below texel size keeps the dither finer than an @@ -301,13 +321,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); let step = reach / (n - 1) as f32; let half = (n - 1) as f32 * 0.5; - let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); + let (gu, gv) = (u * 8.0, v * 8.0); + let (cx, cy) = (gu.floor() as i32, gv.floor() as i32); + let (wx, wy) = (gu - gu.floor(), gv - gv.floor()); let mut lit = 0.0; for j in 0..n { for i in 0..n { - let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); - let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; - let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j); let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the // four is compared against the receiver plane at that centre. diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..f752406d7 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() { assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); } +#[test] +fn penumbra_jitter_is_continuous_across_its_lattice() { + // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice, + // across u and across v, inside the penumbra. The GPU places a receiver within a few + // ulps of the CPU, so coverage that jumps at a lattice line differs between the two + // backends there; both sides of each line must agree within one 8-bit level. + let (m, texels) = wall_map(64, 1.0, 6.0); + let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0); + let mut worst = 0.0f32; + for k in 0..16 { + let line = k as f32 * 0.125; + for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] { + let (fa, fb) = (f(a), f(b)); + assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}"); + worst = worst.max((fa - fb).abs()); + } + } + assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line"); +} + #[test] fn penumbra_width_is_independent_of_map_resolution() { // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and From bdcd2dc051eafdafb7101498cbce711d30fb8676 Mon Sep 17 00:00:00 2001 From: onlykshitij <30444410+onlykshitij@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:05:59 +0530 Subject: [PATCH 178/546] Scripts: subfolders of the Scripts and ScriptUI Panels folders become submenus An extension that ships several panels no longer adds one Window menu entry per panel: `Scripts/ScriptUI Panels/Motion Kit/Animate.jsx` shows as Window > Motion Kit > Animate.jsx, and a subfolder of `Scripts/` is a submenu of File > Scripts. Top-level scripts are unchanged. - Script names may be `Folder/File.jsx`: exactly one folder, each part a bare name (no `..`, `\`, NUL or empty part), never `ScriptUI Panels/...` under `Scripts/`. - window.scriptPanel, file.runScript, file.uninstallScript, file.scripts.list and scriptui_panels.json take such names; a panel's tab is titled after its file. - ConfigStore::list_dirs (DirConfig, MemoryConfig) lists the subfolders. - menus::dynamic_nodes groups a folder's entries into a MenuNode::Submenu; the egui and native menus draw it with their submenu code. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/scripts.rs | 141 +++++++++++++++++++++++--- crates/engine/src/config.rs | 19 ++++ crates/engine/src/menus.rs | 28 ++++- crates/script/src/tests_panels.rs | 24 +++++ crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/native_menu.rs | 11 +- docs/plugins.md | 14 ++- docs/scripting-parity.md | 2 +- 8 files changed, 228 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..996a98cf4 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -5,7 +5,8 @@ //! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts //! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI //! panels appear at the bottom of the Window menu and open as dockable panels -//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of +//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -22,6 +23,8 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. +const PANELS_FOLDER: &str = "ScriptUI Panels"; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects @@ -111,7 +114,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[ /// A script File ▸ Scripts (or the Window menu, for panels) offers. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct ScriptEntry { - /// File name (`Rename Layers.jsx`): what the menus show and commands take. + /// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take. pub name: String, /// In the ScriptUI Panels folder: opens as a dockable panel. pub panel: bool, @@ -124,11 +127,17 @@ fn is_script(name: &str) -> bool { l.ends_with(".jsx") || l.ends_with(".js") } -/// Whether `name` can name an installed script: a bare file name, never a path (names are joined -/// onto the Scripts folders of the settings store, which takes them as relative paths), and on -/// Windows none of the names [`windows_unsafe_name`] lists. +/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a +/// subfolder of a Scripts folder (one level, and not `ScriptUI Panels/…`, the panels' own +/// folder). Each part is a bare name, never a path (names are joined onto the Scripts folders of +/// the settings store, which takes them as relative paths), and on Windows none of the names +/// [`windows_unsafe_name`] lists. fn is_script_name(name: &str) -> bool { - is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name)) + let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n)); + match name.split_once('/') { + Some((folder, file)) => part(folder) && part(file) && !folder.eq_ignore_ascii_case(PANELS_FOLDER), + None => part(name), + } } /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL. @@ -155,8 +164,13 @@ pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { - for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) { - v.push(ScriptEntry { name: n, panel, source: "installed" }); + // The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`. + let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/"))); + for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) { + let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}")); + for n in names.filter(|n| is_script(n) && is_script_name(n)) { + v.push(ScriptEntry { name: n, panel, source: "installed" }); + } } } } @@ -312,7 +326,8 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); - let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); + let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f); + let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) { let id = w.id; s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); @@ -355,13 +370,13 @@ pub fn specs() -> Vec { |s, p| install(s, p, true) ), cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), - crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list), + crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list), cmd!( "window.scriptPanel", "ScriptUI Panel", [], None, - "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel", + "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel", always, open_panel ), @@ -506,7 +521,8 @@ mod tests { for name in BUNDLED.iter().map(|b| b.0).chain(more) { assert!(is_script(name) && is_script_name(name), "{name}"); } - for name in PATHS { + // `a/b.jsx` is a script in a folder (see below). + for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") { assert!(!is_script_name(name), "{name:?}"); } for name in WINDOWS_ONLY { @@ -524,6 +540,107 @@ mod tests { } } + #[test] + fn script_names_may_have_one_folder() { + for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] { + assert!(is_script_name(name), "{name:?}"); + } + // Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`. + let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"]; + for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) { + assert!(!is_script_name(name), "{name:?}"); + } + for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] { + assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}"); + } + } + + /// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`. + fn put(root: &std::path::Path, rel: &str, code: &str) { + let p = root.join("settings").join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, code).unwrap(); + } + + #[test] + fn scripts_in_a_subfolder_are_listed_run_opened_and_uninstalled_as_folder_and_file() { + let (mut s, root) = session_in_tmp("folders"); + put(&root, "Scripts/Tools/Hello.jsx", "hello"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate"); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top"); + // Two folders deep: neither listed nor run. + put(&root, "Scripts/a/b/c.jsx", "too deep"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep"); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let installed: Vec<(&str, bool)> = + list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect(); + assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]); + assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello"); + // A panel in a folder is titled after its file. + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let ran = r["result"]["ran"].as_str().unwrap_or_default(); + assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}"); + assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string()))); + for name in ["../x.jsx", "a/b/c.jsx", "a\\b.jsx", "Motion Kit/sub/Deep.jsx", "ScriptUI Panels/Top.jsx", "Tools/../../../outside.jsx"] { + assert_eq!(script_code(&s, name, None), None, "{name:?}"); + for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] { + let r = s.execute(c, json!({"name": name})); + assert!(bad_params(&r), "{c} `{name}`: {r:?}"); + } + } + assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file())); + assert!(root.join("outside.jsx").is_file()); + for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] { + s.execute("file.uninstallScript", json!({"name": name})).unwrap(); + } + assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| !root.join("settings").join(p).exists())); + let _ = std::fs::remove_dir_all(&root); + } + + /// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder + /// sorts; the entries outside folders stay as they were. + #[test] + fn a_subfolder_is_a_submenu() { + use crate::menus::{MenuNode, dynamic, dynamic_nodes}; + let (s, root) = session_in_tmp("submenus"); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", ""); + put(&root, "Scripts/ScriptUI Panels/Top.jsx", ""); + put(&root, "Scripts/Tools/Hello.jsx", ""); + let cx = Default::default(); + let item = + |label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null}); + let menus = [ + ( + "scriptPanels", + json!({"type": "submenu", "label": "Motion Kit", "children": [ + item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"), + item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"), + ]}), + ), + ("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})), + ]; + for (menu, folder) in menus { + let (nodes, _) = dynamic_nodes(&s, menu, &cx); + let (entries, _) = dynamic(&s, menu, &cx); + let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect(); + assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::>(), "{menu}"); + let folders: Vec = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect(); + assert_eq!(folders, [folder], "{menu}"); + } + let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx); + let labels: Vec<&str> = panels + .iter() + .filter_map(|n| match n { + MenuNode::Item(e) => Some(e.label.as_str()), + MenuNode::Submenu { label, .. } => Some(label), + _ => None, + }) + .collect(); + assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn uninstall_takes_a_script_name_not_a_path() { let (mut s, root) = session_in_tmp("uninstall"); diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 00e7377b7..af6a1d800 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -32,6 +32,11 @@ pub trait ConfigStore: Send + Sync { let _ = dir; vec![] } + /// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`]. + fn list_dirs(&self, dir: &str) -> Vec { + let _ = dir; + vec![] + } /// The folder next to the configuration (default Auto-Save folder for untitled projects), /// in the namespace of [`ConfigStore::files`]; `None` when there is none. fn dir(&self) -> Option { @@ -107,6 +112,14 @@ impl ConfigStore for MemoryConfig { let Ok(m) = self.files.lock() else { return vec![] }; m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect() } + fn list_dirs(&self, dir: &str) -> Vec { + let pre = format!("{}/", dir.trim_end_matches('/')); + let Ok(m) = self.files.lock() else { return vec![] }; + let mut v: Vec = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect(); + v.sort(); + v.dedup(); + v + } } /// Files in one directory, written atomically. @@ -153,6 +166,12 @@ impl ConfigStore for DirConfig { v.sort(); v } + fn list_dirs(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] }; + let mut v: Vec = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v + } fn remove(&self, name: &str) -> std::io::Result<()> { match std::fs::remove_file(self.dir.join(name)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index aa2e4ffe1..6e82a8057 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -75,7 +75,8 @@ fn holder_of(s: &Session, keys: &str) -> Option { /// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and -/// `scriptPanels` (ScriptUI panels). +/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here, +/// and [`dynamic_nodes`] puts it in the folder's submenu). pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { use serde_json::json; let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec { @@ -153,6 +154,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option< } } +/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder +/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸ +/// Animate.jsx`), placed where the folder sorts among the other entries. +pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { + let (entries, empty) = dynamic(s, name, cx); + let scripts = matches!(name, "scripts" | "scriptPanels"); + let mut out: Vec = vec![]; + for mut e in entries { + let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else { + out.push(MenuNode::Item(e)); + continue; + }; + e.label = file; + let open = out.iter_mut().find_map(|n| match n { + MenuNode::Submenu { label, children } if *label == folder => Some(children), + _ => None, + }); + match open { + Some(children) => children.push(MenuNode::Item(e)), + None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }), + } + } + (out, empty) +} + /// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or /// workspace, like After Effects). pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String { diff --git a/crates/script/src/tests_panels.rs b/crates/script/src/tests_panels.rs index c398a3a3f..3318d060f 100644 --- a/crates/script/src/tests_panels.rs +++ b/crates/script/src/tests_panels.rs @@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() { let mut s = session(&store); assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []})); } + +/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens +/// titled after its file and opens again at the next launch. +#[test] +fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap(); + let mut s = session(&store); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}"); + let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap(); + let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate")); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); + // A saved name that leaves the folder, or goes two folders deep, fails and is forgotten. + store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap(); + drop(s); + let mut s = session(&store); + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"])); + assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["../Top.jsx", "a/b/c.jsx"]); + assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some()); + assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]})); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea9a6d91e..1338f3f2e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2209,13 +2209,18 @@ fn menu_nodes( // saved workspaces. let ws = app.ui.workspace.clone(); let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved)); + if nodes.is_empty() && let Some(e) = empty { ui.add_enabled(false, egui::Button::new((gutter(false), e))); } - for (i, e) in entries.iter().enumerate() { + for (i, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth); + continue; + }; let tip = match name.as_str() { "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(), diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 7d7046ed3..48a77d207 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -138,8 +138,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { // Recent projects / footage / presets, undo history, shortcut slots, viewers, // saved workspaces. let saved = app.saved_workspace_names(); - let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); - if entries.is_empty() + let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved)); + if nodes.is_empty() && let Some(e) = empty { out.push(NativeNode::Item(NativeItem { @@ -153,7 +153,12 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { checked: None, })); } - for (k, e) in entries.iter().enumerate() { + for (k, n) in nodes.iter().enumerate() { + // A folder of scripts or panels: a submenu. + let MenuNode::Item(e) = n else { + rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out); + continue; + }; out.push(NativeNode::Item(NativeItem { id: format!("{id}.d{k}"), label: e.label.clone(), diff --git a/docs/plugins.md b/docs/plugins.md index a688a89b0..d4f40f097 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -12,11 +12,11 @@ Guide): | After Effects | EffectCraft | | |---|---|---| | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | -| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | +| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | -| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | +| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | @@ -40,6 +40,9 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. +* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts + are named `Folder/File.jsx` in these commands. One level of folders is listed; deeper folders + are not. ## ScriptUI panels @@ -51,6 +54,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps run at start-up; the list is `scriptui_panels.json` in the settings folder). +To group panels, for example the several panels of one extension, make a subfolder of +`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is +listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it +`My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install +ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it +yourself. + Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating palette itself. A minimal panel: diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 7ef7e9080..424752a80 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -22,7 +22,7 @@ our own scripts; no real-world script library has been run against both apps. | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | -| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | +| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | From 1c9041478ce562596ab20587242f77e44e9949b0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:43:37 +0000 Subject: [PATCH 179/546] Keep FrameRate::from_f64 nonzero for tiny positive fps Found while testing crates/time/src/lib.rs:201 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/time/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 7a64a82bb..265c0c389 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -198,7 +198,9 @@ impl FrameRate { return r; } } - FrameRate::new((fps * 1000.0).round() as i64, 1000) + let num = (fps * 1000.0).round() as i64; + // A tiny positive rate must not round to the invalid 0/1 rate. + FrameRate::new(if fps > 0.0 { num.max(1) } else { num }, 1000) } pub fn as_f64(self) -> f64 { @@ -533,6 +535,12 @@ pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_ mod tests { use super::*; + #[test] + fn from_f64_tiny_positive_fps_is_nonzero() { + let r = FrameRate::from_f64(0.0001); + assert!(r.num > 0 && r.den > 0, "got {r:?}"); + } + #[test] fn frame_rate_constructors_accept_extreme_numbers() { assert_eq!(FrameRate::new(i64::MIN, 1), FrameRate { num: i64::MIN, den: 1 }); From dbe92141d21e0708cd2434807a8ee0f525be06a4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:43:44 +0200 Subject: [PATCH 180/546] Bound SVG arc segment count for huge radii Fixes #2128 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/pathdata.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/pathdata.rs b/crates/svg/src/pathdata.rs index c8fa7f67f..d70f114b8 100644 --- a/crates/svg/src/pathdata.rs +++ b/crates/svg/src/pathdata.rs @@ -227,7 +227,11 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las let e = pt(l)?; let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep }; match kurbo::Arc::from_svg_arc(&arc) { - Some(a) => a.to_cubic_beziers(0.1, |c1, c2, end| p.curve_to(c1, c2, end)), + Some(a) => { + // Scale tolerance with the radius so the segment count stays bounded. + let tol = 0.1_f64.max(a.radii.x.max(a.radii.y) * 1e-4); + a.to_cubic_beziers(tol, |c1, c2, end| p.curve_to(c1, c2, end)) + } None => p.line_to(e), } *cur = e; @@ -261,6 +265,12 @@ mod tests { use super::*; use kurbo::PathEl; + #[test] + fn huge_arc_radius_stays_bounded() { + let p = parse_path_data("M0 0A1e36 1e36 0 1 1 1e30 0"); + assert!(p.elements().len() < 1000, "got {} elements", p.elements().len()); + } + #[test] fn commands() { let p = parse_path_data("M10,10 L20,10 h10 v10 H10 z m5 5 l1-1.5.5.5e1 C 0 0 1 1 2 2 s3 3 4 4 Q1 1 2 2 t3 3 A5 5 0 0 1 20 20 a5 5 0 1010 0"); From eb2a4820e83036c3e58d34f78137002d10939fe5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:59:10 -0700 Subject: [PATCH 181/546] Scripts: Recent Script Files lists the last ten scripts and Cmd+Alt+Shift+D reruns the newest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Scripts gets a Recent Script Files submenu with the ten scripts run last, newest first: script files run with Run Script File… or a command, and the installed and bundled scripts of File ▸ Scripts. ScriptUI panels are not listed. The list is kept in the settings. Each entry runs its script again through `file.runScript {"recent": i}`, and Cmd+Alt+Shift+D runs the most recent one; the key can be changed in Edit ▸ Keyboard Shortcuts. An entry records whether it ran a file or an installed script, so a file named like a bundled script runs again as that file. --- crates/engine/src/commands/file_more.rs | 28 +++++++++++++- crates/engine/src/commands/scripts.rs | 50 +++++++++++++++++++++++++ crates/engine/src/menus.rs | 9 ++++- crates/engine/src/prefs.rs | 33 ++++++++++++++++ crates/engine/src/shortcuts.rs | 15 ++++++++ crates/ui-egui/src/i18n.rs | 11 ++++++ crates/ui-egui/src/menus.rs | 22 ++++++++++- crates/ui-egui/src/native_menu.rs | 1 + docs/plugins.md | 1 + docs/scripting-parity.md | 1 + 10 files changed, 168 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 74a86e1d8..e92b43dc0 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -430,12 +430,29 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's /// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { + // File ▸ Scripts ▸ Recent Script Files ▸