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/916] 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/916] 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/916] 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/916] 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/916] 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/916] 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/916] #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/916] #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/916] #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/916] #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/916] 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/916] 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/916] 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/916] #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/916] #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/916] #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/916] #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/916] 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/916] 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/916] 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 058da6f3b053e42161d73670300eaffbd7b93723 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 021/916] CI: use a match guard in the STL reader --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From 037a21d5a6330a47b338daad0109f6a09419249f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 022/916] #1200: allow TGA proxies and add import regressions --- contributors/people.toml | 2 +- crates/host/tests/tga_import.rs | 48 +++++++++++++ crates/media/tests/tga.rs | 115 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 19 +++++- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 crates/host/tests/tga_import.rs create mode 100644 crates/media/tests/tga.rs diff --git a/contributors/people.toml b/contributors/people.toml index 705feb257..0de777b11 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/tga_import.rs new file mode 100644 index 000000000..5462f4bec --- /dev/null +++ b/crates/host/tests/tga_import.rs @@ -0,0 +1,48 @@ +//! Targa import through the same host and commands used by the desktop and CLI. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +#[test] +fn tga_stills_and_numbered_sequences_import_and_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained. + let write = |name: &str, bgr: [u8; 3]| { + let mut bytes = vec![0; 18]; + bytes[2] = 2; + bytes[12] = 4; + bytes[14] = 4; + bytes[16] = 24; + bytes[17] = 0x20; + bytes.extend_from_slice(&bgr.repeat(16)); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() + }; + let still = write("still.TGA", [0, 0, 255]); + let first = write("shot_0001.tga", [0, 0, 255]); + write("shot_0002.tga", [255, 0, 0]); + for (path, sequence) in [(still, false), (first, true)] { + let mut session = effectcraft_host::session(); + let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap(); + assert_eq!(imported["errors"], json!([]), "{imported}"); + assert_eq!(imported["items"].as_array().unwrap().len(), 1); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") }; + assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still }); + assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 }); + session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let image = session.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + if sequence { + let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default()); + assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/media/tests/tga.rs b/crates/media/tests/tga.rs new file mode 100644 index 000000000..61aacd957 --- /dev/null +++ b/crates/media/tests/tga.rs @@ -0,0 +1,115 @@ +//! Tiny synthetic Targa files exercise import without external fixtures or codecs. + +use std::path::Path; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, is_importable, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::Tick; + +/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE +/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order. +fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec { + let mut bytes = vec![0; 18]; + bytes[2] = if rle { 10 } else { 2 }; + bytes[12] = 2; + bytes[14] = 2; + bytes[16] = if alpha { 32 } else { 24 }; + bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 }; + let order = if top { [0, 1, 2, 3] } else { [2, 3, 0, 1] }; + for row in order.chunks_exact(2) { + if rle { + assert_eq!(pixels[row[0]], pixels[row[1]]); + bytes.push(0x81); // A two-pixel run. + } + for &i in row.iter().take(if rle { 1 } else { 2 }) { + let [r, g, b, a] = pixels[i]; + bytes.extend_from_slice(&[b, g, r]); + if alpha { + bytes.push(a); + } + } + } + bytes +} + +#[test] +fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() { + for alpha in [false, true] { + for rle in [false, true] { + for top in [false, true] { + let pixels = if rle { + [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]] + } else { + [[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]] + }; + let path = if top { "/frame.TGA" } else { "/frame.tga" }; + assert!(is_importable(Path::new(path))); + let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into(); + let footage = probe_bytes(path, bytes.clone()).unwrap(); + assert_eq!(footage.kind, FootageKind::Still); + assert_eq!((footage.width, footage.height), (2, 2)); + assert_eq!(footage.codec, "TGA"); + assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }); + assert!(footage.has_video && !footage.has_audio); + let pool = MediaPool::new(); + pool.add_bytes(path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) { + let a = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a]; + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}"); + } + } + } + } +} + +#[test] +fn tga_sequence_decodes_each_frame_from_memory() { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let paths = ["/shot_0001.tga", "/shot_0002.tga"]; + let colours = [[255, 0, 0, 255], [0, 0, 255, 255]]; + let mut footage = None; + for (path, colour) in paths.iter().zip(colours) { + let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into(); + footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap()); + pool.add_bytes(path, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() }; + footage.sync_sequence_duration(); + for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() { + let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap(); + assert_eq!(image.get(0, 0), expected); + } +} + +#[test] +fn malformed_tga_returns_contextual_errors() { + for bytes in [vec![], vec![0; 17], vec![0; 18]] { + let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string(); + assert!(error.contains("/broken.tga"), "{error}"); + } + let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true); + bytes.truncate(19); // Valid metadata, truncated pixel data. + let bytes: Arc<[u8]> = bytes.into(); + let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap(); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string(); + assert!(error.contains("/truncated.tga"), "{error}"); +} + +#[test] +fn a_recognised_signature_takes_precedence_over_the_tga_extension() { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap(); + assert_eq!(footage.codec, "PNG"); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + assert_eq!((image.width, image.height), (3, 2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..53783859e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1361,7 +1361,9 @@ 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::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) + } _ => return None, }; if params.get(key).is_some() @@ -2494,6 +2496,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn tga_is_available_in_import_replace_and_proxy_dialogs() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let pending = Arc::new(Mutex::new(Vec::new())); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] { + assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command})); + let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") }; + assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}"); + } + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 2ddbf20466e79b71e53dc565d1bb0b22c814e926 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:31:17 -0400 Subject: [PATCH 023/916] #1070: refresh layered composition sources on import --- crates/engine/src/commands/file.rs | 7 +++ crates/host/tests/footage_reload.rs | 77 +++++++++++++++++++++++++++++ docs/footage.md | 3 ++ 3 files changed, 87 insertions(+) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb633..0b131ec7a 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -415,6 +415,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } } paths = rest; + // Composition imports already made their sources, so they bypass add_footage's + // probed-footage refresh. Refresh them before any background probe job starts (#1070). + for id in &items { + if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) { + s.forget_decoded(f); + } + } } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 6834fc543..faf2b09e3 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() { let cid = s.active_comp_id().unwrap(); assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24)); } + +fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId { + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + ItemId(r["comps"][0].as_u64().unwrap()) +} + +/// Composition imports create their sources before probing ordinary footage (#1070). +#[test] +fn fresh_layered_composition_import_refreshes_its_sources() { + for import_as in ["composition", "compositionLayerSizes"] { + let path = tmp(&format!("fresh-{import_as}.psd")); + let (red, blue) = (flat(RED, false), flat(BLUE, false)); + assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size"); + std::fs::write(&path, red).unwrap(); + let mut s = effectcraft_host::session(); + let old_comp = layered_comp(&mut s, &path, import_as); + assert!(near(pixel(&s, old_comp, 32, 24), RED)); + s.execute_checked("file.newProject", json!({})).unwrap(); + save(&path, &blue); + s.drain_events(); + let comp = layered_comp(&mut s, &path, import_as); + assert_eq!(comp, old_comp, "new projects reuse their item ids"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24)); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() { + let path = tmp("layered-reimport.psd"); + let other = tmp("layered-unchanged.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + std::fs::write(&other, flat(GREEN, false)).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap(); + let other_item = ItemId(r["items"][0].as_u64().unwrap()); + let other_footage = footage(&s, other_item); + let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap(); + assert!(near(pixel(&s, comp, 32, 24), RED)); + let before = s.project.clone(); + let undo = s.history.undo.len(); + save(&path, &flat(BLUE, false)); + s.drain_events(); + layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source"); + assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap())); + assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step"); + s.undo(); + assert!(std::sync::Arc::ptr_eq(&s.project, &before)); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write"); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} + +#[test] +fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() { + let path = tmp("layered-background.psd"); + let other = tmp("layered-background.png"); + std::fs::write(&path, flat(RED, false)).unwrap(); + image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(near(pixel(&s, comp, 32, 24), RED)); + save(&path, &flat(BLUE, false)); + s.drain_events(); + let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap(); + assert_eq!(r["started"], true, "ordinary footage is probed in a background job"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied"); + s.wait_jobs(); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE)); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} diff --git a/docs/footage.md b/docs/footage.md index 4a5a498c7..84d4c49e6 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -107,6 +107,9 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la ## Replacing and reloading +Importing a layered file as a composition refreshes the decoded sources used by its layers, +including files overwritten at the same path and byte size. + - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). From c75ea46c8647459f4c7e186be878904286523244 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 07:56:29 -0400 Subject: [PATCH 024/916] 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 025/916] 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 dbc595b0d6cd37db886fc0b34057c8360b6c4f1a Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:06 +0300 Subject: [PATCH 026/916] #1220: Hide layer bounds during path editing --- crates/ui-egui/src/panels/viewer.rs | 8 +++ crates/ui-egui/tests/ui_viewer.rs | 97 +++++++++++++++++++++++++++++ docs/gaps.md | 6 +- docs/ui-parity.md | 9 ++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..6ec3be36c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1028,6 +1028,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Path editing keeps vertices and tangents visible without the layer's transform + // box competing with them (#1220). The path's own free-transform box is separate. + let editing_path = paths.iter().any(|p| { + p.layer == l.id && (app.ui.tool.is_pen() || (app.ui.tool == Tool::Selection && sel_vertices.iter().any(|v| v.layer == l.id && v.mask == p.uid))) + }); + if editing_path { + continue; + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // A 3D layer reaching past the camera: the part of its box in front of it. let outline = layer_outline(&ectx, l); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1d..d37180a8e 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -677,6 +677,103 @@ fn fill_options_change_the_selected_shape_layer() { } } +/// #1220: path tools hide the layer transform box, while path vertices remain editable. +#[test] +fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() { + for mask in [false, true] { + let mut h = harness(); + let vertices = [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]]; + let (layer, uid, kind) = if mask { + let layer = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + let r = h + .state_mut() + .session + .execute("mask.new", json!({"layer": layer, "vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true})) + .unwrap(); + (layer, r["mask"].as_u64().unwrap(), "mask") + } else { + let r = h.state_mut().session.execute("shape.newPath", json!({"vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true, "fill": false, "stroke": "#ffffff", "strokeWidth": 4})).unwrap(); + (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap(), "shapePath") + }; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "ordinary layer selection has transform handles (mask={mask})"); + for tool in [Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none(), "{tool:?}: hide the layer box"); + assert!(h.state().auto.find(&format!("viewer.{kind}.{uid}.vertex.0")).is_some(), "{tool:?}: keep the path point"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let vertex = format!("viewer.{kind}.{uid}.vertex.0"); + let from = rect(&h, &vertex).center(); + click(&mut h, from); + h.run_steps(2); // The automation registry also keeps the preceding frame's elements. + assert!(h.state().auto.find(&handle).is_none(), "selecting a vertex starts path editing (mask={mask})"); + let position = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let path = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .session + .active_comp() + .unwrap() + .layer(LayerId(layer)) + .unwrap() + .props + .find_group(uid) + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone() + }; + let before = path(&h); + drag(&mut h, from, from + vec2(15.0, 12.0)); + assert_ne!(path(&h).vertices[0], before.vertices[0], "the corner vertex can still be moved"); + assert_eq!(path(&h).vertices[1..], before.vertices[1..]); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value, position); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "moving the vertex undoes in one step"); + let z = rect(&h, "viewer.comp").width() / 640.0; + let tangent = rect(&h, &vertex).center() + vec2(40.0 * z, 0.0); + drag(&mut h, tangent, tangent + vec2(0.0, 20.0)); + assert_ne!(path(&h).out_tangents[0], before.out_tangents[0], "the Bézier handle is still editable"); + assert_eq!(path(&h).vertices, before.vertices, "a tangent drag does not move the path points"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "the tangent drag undoes in one step"); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "a layer transform tool restores the box"); + h.state_mut().ui.tool = Tool::Selection; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "ending vertex selection restores the box"); + } +} + +#[test] +fn path_tools_keep_bounds_on_layers_without_visible_paths() { + let mut h = harness(); + let layer = layer_id(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "a layer without a path keeps its box"); + h.state_mut().session.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [80, 0], [80, 80]], "closed": true})).unwrap(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none()); + h.state_mut().ui.viewer.show_masks = false; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "hidden paths do not suppress the layer controls"); +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..0c5e30949 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-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) 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 @@ -114,6 +114,9 @@ The most important missing piece: it turns every other estimate here into a meas - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. +- Path editing (#1220): selected shape and mask paths suppress the layer transform box while + their points are edited. `ui_viewer` regressions cover Pen-family tools, Selection vertex + dragging and undo, restoration of layer controls, and layers without visible paths. - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check @@ -322,6 +325,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668da..366d8f770 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -33,6 +33,12 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | +Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while +using a Pen-family tool on a visible path, or while selecting its vertices with the Selection +tool. Path points, Bézier handles and the path’s free-transform controls remain available. +Layer transform tools and ordinary layer selection restore the box. Headless `ui_viewer` tests +cover shape paths and masks, corner-vertex dragging and undo, and layers without visible paths. + ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted @@ -44,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 2720ae5fe29d721622502e34eb0504b05a46d726 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 23:40:32 +1100 Subject: [PATCH 027/916] Wave Warp: cover the Top/Bottom edge pinning paths end to end (#1181) The pinning regression only exercised All Edges (and Left Edge in the CPU unit test), so the reported 'does not work with Top/Bottom Edge' had no coverage to catch or clear it. This holds the pinned first/last row in a CPU test at Direction 90, and adds CPU/GPU parity cases for Top (4), Bottom (6), diagonal directions, and single-edge pinning on shape layers. CPU pinning verified correct on main: the guards in dst_wave_pin/wave_pin agree; the gap was that neither edge path was tested. --- crates/effects/src/distort.rs | 14 +++++++++++++- crates/gpu/src/tests_fx_distort.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index a68de1094..195ac8985 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -556,8 +556,20 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + } } #[test] diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index e0e89fb67..19cdcd605 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() { effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); + // #1181: the single-edge pinning paths, with the default vertical displacement direction. + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]); + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]); } #[test] @@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) { fn edge_pinning_on_a_shape_layer() { shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]); + // #1181: single-edge pinning over the layer rect, not just All Edges. + shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); + shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); shape_case( "ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))], From 48738093b571322646247cfc91921aff804fce95 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:02:12 +1100 Subject: [PATCH 028/916] Shapes: Merge Paths keeps paint that belongs to a group above it (#1174) A Merge Paths item appended to a shape layer's Contents cleared every draw collected so far, including fills and strokes inside groups above it, so the layer's shapes vanished from the render. AE's rule is per group: only paint items in the merge's own group above it are dropped. Draws now record which contents level created them; the merge drops only its own group's paint, and the group-bubbled draws keep rendering. Adds a regression test (Merge Shapes and Add modes) that renders nothing without the fix. --- crates/render/src/shapes.rs | 13 +++++++++++-- crates/render/src/shapes/tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa501..d5e1d5b85 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -57,6 +57,10 @@ struct Draw { blend: BlendMode, /// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D). surfaces: SurfaceColors, + /// Paint item of the contents group this draw was collected in (as opposed to one bubbled up + /// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own + /// fill or stroke keeps rendering inside the group. + local: bool, } /// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`] @@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; @@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}"); + assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img)); + } +} + #[test] fn merge_subtract_and_intersect() { let sub = |mode: u32| { From fb715d9015503d22b1da8ac511857e6fd485d35f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:32:56 +1100 Subject: [PATCH 029/916] 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 030/916] 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 031/916] 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 032/916] 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 5270eab095a04347eed080db7a50f0e63bd75027 Mon Sep 17 00:00:00 2001 From: Ian-Chege Date: Tue, 13 Oct 2026 19:15:30 +0300 Subject: [PATCH 033/916] Masks: Mask Expansion past a curve's radius grows a solid mask instead of a ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expansion was a fill plus a stroke 2·|expansion| wide, blended per pixel. Once the expansion passed the shape's curvature radius the stroke's inner edge inverted: a 20 px circle expanded by 60 left a ring with the layer showing through the middle, +600 cut only the original dot, and negative values left islands. The outline is now offset with Offset Paths' geometry (round joins) and filled. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 20 ++---- crates/render/src/tests_mask_expansion.rs | 82 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_expansion.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..655009bb4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2390,6 +2390,8 @@ mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; #[cfg(test)] +mod tests_mask_expansion; +#[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 3f8d7542b..bb91ef71d 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -3,7 +3,7 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; -use effectcraft_path::{FillRule, StrokeStyle}; +use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; @@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let variable = !sp.feather.is_empty(); let mut cov = if variable { effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, 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) } else { effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) }; - if exp.abs() > 0.01 && !variable { - let ring = effectcraft_path::stroke_coverage( - std::slice::from_ref(&path), - &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, - &m, - w, - h, - ); - cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| { - *c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) }; - }); - } let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); diff --git a/crates/render/src/tests_mask_expansion.rs b/crates/render/src/tests_mask_expansion.rs new file mode 100644 index 000000000..700c779aa --- /dev/null +++ b/crates/render/src/tests_mask_expansion.rs @@ -0,0 +1,82 @@ +//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the +//! shape's curvature radius it must still cover everything within the distance of the path, +//! never leave a ring around an uncovered middle. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels. +fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]); + p.next_id = next; + m.get_mut("expansion").unwrap().value = Value::Scalar(expansion); + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole). +fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec { + let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + points.iter().map(|&(x, y)| img.get(x, y)[3]).collect() +} + +#[test] +fn no_expansion_cuts_the_circle() { + let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]); + assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}"); + assert!(a[2] > 0.99, "outside it is kept: {a:?}"); +} + +#[test] +fn expansion_past_the_radius_grows_a_solid_hole() { + // +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring + // from 40 to 80 px with the layer showing at 21–39 px. + let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]); + assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}"); + assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}"); +} + +#[test] +fn huge_expansion_cuts_the_whole_frame() { + // +600 reaches every pixel; the blend left only the 20 px circle cut. + let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]); + assert!(a.iter().all(|&v| v < 0.01), "{a:?}"); +} + +#[test] +fn negative_expansion_shrinks_then_removes_the_hole() { + let a = hole(-15.0, &[(200, 150), (210, 150)]); + assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}"); + assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}"); + let a = hole(-25.0, &[(200, 150), (190, 150)]); + assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}"); +} + +#[test] +fn expanded_rect_add_mask_has_round_corners() { + // A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off. + let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside"); + assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside"); + // 25 px diagonally out from the corner (250,100) is within 30 px: inside. + assert!(img.get(267, 82)[3] > 0.99); + // The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it. + assert!(img.get(277, 73)[3] < 0.01); +} From 6f57b9eee3632adeecf106d8ccf80ff5aad0bdba Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 12:19:35 -0400 Subject: [PATCH 034/916] 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 035/916] 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 036/916] 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 037/916] 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 038/916] 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 d309d6f1d0d59362eb6213f4164ef777a3f0a510 Mon Sep 17 00:00:00 2001 From: Lloyd Nicholson Date: Tue, 13 Oct 2026 18:53:31 +0200 Subject: [PATCH 039/916] Effects & Presets: drag animation presets onto a layer Preset rows (text, built-in and user .ecpreset) were click-only, so dragging one onto a layer did nothing. They now start a DragPayload::Preset drag with a ghost, and the Timeline, Composition viewer and Effect Controls apply the preset to the layer it is dropped on, as they already do for effects and as After Effects does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 10 ++-- crates/ui-egui/src/panels/effects_presets.rs | 41 +++++++-------- crates/ui-egui/src/panels/mod.rs | 52 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 16 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 19 +++++-- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..4d3bf833e 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1394,14 +1394,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects here. + // Drop effects and animation presets here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) - && let crate::panels::DragPayload::Effect(e) = payload.as_ref() + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), + _ => None, + } { p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]}))); + actions.push((id.into(), params)); egui::DragAndDrop::clear_payload(&ctx); } } diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..a9410d98e 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -2,15 +2,15 @@ //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an //! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition //! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the -//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies -//! it there. +//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer, +//! Effect Controls) applies it there. use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use crate::icons::{self, Icon}; -use crate::panels::DragPayload; +use crate::panels::{DragPayload, PresetRef}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -107,10 +107,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), - TextPreset(String), - /// A built-in animation preset (Transitions…) by id. - Builtin(String), - PresetFile(String), + /// A text, built-in (Transitions…) or user file animation preset. + Preset(PresetRef), } /// A folder row; returns whether it is open. @@ -217,7 +215,7 @@ fn effect_row( } } -/// A preset row (no drag; double-click applies). +/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there. #[allow(clippy::too_many_arguments)] fn preset_row( app: &mut EffectcraftApp, @@ -228,7 +226,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: Apply, + preset: PresetRef, apply: &mut Option, ) { let t = app.tokens; @@ -237,15 +235,18 @@ fn preset_row( if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); + let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag()); if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() }); + } if resp.double_clicked() { - *apply = Some(on_apply); + *apply = Some(Apply::Preset(preset)); } } @@ -395,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - Apply::PresetFile(f.path.clone()), + PresetRef::File(f.path.clone()), &mut apply, ); } @@ -455,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); - if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && let Some(pos) = ctx.pointer_hover_pos() + if let Some(name) = egui::DragAndDrop::payload::(&ctx).and_then(|p| match p.as_ref() { + DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()), + DragPayload::Preset { name, .. } => Some(name.clone()), + _ => None, + }) && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); @@ -473,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), - Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), - Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), - Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + Apply::Preset(preset) => preset.command(), }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..4718d79d0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -75,12 +75,49 @@ pub enum DragPayload { Item(u64), /// An effect id from Effects & Presets. Effect(String), + /// An animation preset from Effects & Presets, with its display name for the drag ghost. + Preset { preset: PresetRef, name: String }, /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, /// Files from the Media Browser (import; into the timeline: import and add). Files(Vec), } +/// An animation preset in Effects & Presets. +#[derive(Clone, Debug, PartialEq)] +pub enum PresetRef { + /// A text animation preset id (`layer.applyTextPreset`). + Text(String), + /// A built-in animation preset id, e.g. a transition (`anim.applyBuiltinPreset`). + Builtin(String), + /// A user `.ecpreset` file path (`anim.applyPreset`). + File(String), +} + +impl PresetRef { + /// The command and params that apply the preset to the selected layers. + pub fn command(&self) -> (&'static str, serde_json::Value) { + match self { + PresetRef::Text(id) => ("layer.applyTextPreset", serde_json::json!({"preset": id})), + PresetRef::Builtin(id) => ("anim.applyBuiltinPreset", serde_json::json!({"preset": id})), + PresetRef::File(path) => ("anim.applyPreset", serde_json::json!({"path": path})), + } + } + + /// The command and params that apply the preset to `layer` (a drop target). + pub fn apply_to(&self, layer: u64) -> (&'static str, serde_json::Value) { + let (id, mut params) = self.command(); + if let Some(obj) = params.as_object_mut() { + // `layer.applyTextPreset` takes one `layer`; the animation preset commands take `layers`. + match self { + PresetRef::Text(_) => obj.insert("layer".into(), serde_json::json!(layer)), + PresetRef::Builtin(_) | PresetRef::File(_) => obj.insert("layers".into(), serde_json::json!([layer])), + }; + } + (id, params) + } +} + /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. /// Folders and data files are not composition layers. @@ -249,3 +286,18 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } + +#[cfg(test)] +mod preset_drop_tests { + use super::PresetRef; + use serde_json::json; + + #[test] + fn preset_drops_target_the_layer_with_each_commands_param() { + assert_eq!(PresetRef::Text("typewriter".into()).apply_to(7), ("layer.applyTextPreset", json!({"preset": "typewriter", "layer": 7}))); + assert_eq!(PresetRef::Builtin("fadeIn".into()).apply_to(7), ("anim.applyBuiltinPreset", json!({"preset": "fadeIn", "layers": [7]}))); + assert_eq!(PresetRef::File("/p/a.ecpreset".into()).apply_to(7), ("anim.applyPreset", json!({"path": "/p/a.ecpreset", "layers": [7]}))); + // Double-click applies to the selection: no layer given. + assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..b59a5d96f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3320,7 +3320,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and + // Drop targets: effects and animation presets from Effects & Presets go on the layer under the + // pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too // (Shift: at the current time). @@ -3329,11 +3330,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { + if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); if released && let Some(l) = target { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); + match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => { + let (id, params) = preset.apply_to(l.0); + actions.push((id.into(), params)); + } + _ => {} + } } } else { let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); @@ -3369,7 +3377,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } - crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {} + crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {} } } } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 2d42eb2c9..44364a49d 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,8 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped -//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined -//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`, -//! `mediaBrowser.import {position}` and `effect.apply {layers}`. +//! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the +//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::EvalCtx; @@ -33,6 +33,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Preset { preset, .. } => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + preset.apply_to(l.id.0) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -45,7 +54,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], }; - if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { + // Layer drops outline the target layer; the others outline the whole viewer. + let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { From 6bf9c87a1a5983c9affda53c4020ccd1123dda55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 19:07:25 +0200 Subject: [PATCH 040/916] 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 041/916] 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 042/916] 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 043/916] 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 044/916] 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 045/916] 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 046/916] Docs: the GPU frame guard catches validation errors (refs #1319) --- docs/architecture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index e5564c22a..bec75b3d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -345,7 +345,10 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani (`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory (`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and -the RAM preview keeps half as many GPU frames from then on. A frame whose render panics is +the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's +validation errors (an invalid texture, often left by a failed allocation): that frame renders on +the CPU and the kept textures are freed, but the compositor stays on and, with no allocation +failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is rendered again on the CPU or released, never left in progress. Native readbacks and timing waits share a finite deadline; failure retires pending readbacks exactly once, and late results are discarded. The desktop executable and browser page own their shared device's error/loss handlers and forward From c979f585ac4cb3c456394dda151889af5a9e5846 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:47:22 -0400 Subject: [PATCH 047/916] Timeline: focus newly added expressions after Alt-click (refs #1072) Alt-clicking a stopwatch added an expression, but its editor needed another click before typing went into it. In After Effects the new expression's field takes the keyboard at once, its text selected. The pick whip's one-shot refocus (the editor takes the keyboard back after a reference goes in) is now an ExprFocus request for any property: which text to select, counted from the end, and the pass it was asked for in. An Alt-click that adds an expression asks for it with the whole text selected, so typing replaces it; the expression row is opened if it was closed and scrolled into view (it is added below the stopwatch's row, which can be the last one shown). The request is taken once, when the editor is drawn, and dropped if its expression doesn't appear or it isn't taken within a few passes, so it never takes the keyboard later; an Alt-click that removes the expression clears it. Tests (ui_inline_edit): typing straight after the Alt-click replaces the new expression; a field clicked right after keeps the keyboard; after removing it nothing has the keyboard; an expression added at the Timeline's bottom brings its editor into view with the keyboard. Both fail on main. --- crates/ui-egui/src/panels/timeline.rs | 58 +++++++++++++--- crates/ui-egui/tests/ui_inline_edit.rs | 92 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..72c071107 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -265,10 +265,25 @@ fn whip_into_id(prop: u64) -> egui::Id { egui::Id::new(("tl-pickwhip-into", prop)) } -/// After a pick whip into the expression being edited: its editor takes the focus back, the -/// cursor this many characters before the end (just after the reference). -fn expr_refocus_id(prop: u64) -> egui::Id { - egui::Id::new(("tl-expr-refocus", prop)) +/// An expression editor to take the keyboard when it is next drawn: its property, the text to +/// select as counts of characters before the end (so it fits the expression as it will be then) +/// and the pass it was asked for in. After a pick whip into the expression being edited, the +/// cursor goes just after the reference; after an Alt-click on a stopwatch adds an expression, +/// its whole text is selected, so typing replaces it. +#[derive(Clone, Copy, Debug)] +struct ExprFocus { + prop: u64, + select: [usize; 2], + pass: u64, +} + +fn expr_focus_id() -> egui::Id { + egui::Id::new("tl-expr-focus") +} + +fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass })); } #[derive(Clone, Debug)] @@ -1888,6 +1903,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); + // An expression editor asked to take the keyboard comes into view; a request whose + // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never + // takes the keyboard later). + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())) { + match rows.iter().position(|r| matches!(r.kind, RowKind::Expr { uid, .. } if uid == f.prop)) { + Some(i) if ctx.cumulative_pass_nr() <= f.pass.saturating_add(8) => { + let top: f32 = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); + let fits = (top + rows.get(i).map_or(rh, |r| row_height(r, rh)) - rows_rect.height()).max(0.0).min(top); + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(top).max(fits); + } + _ => ctx.data_mut(|d| d.remove::(expr_focus_id())), + } + } if scroll_to == Some(crate::state::ScrollTo::Layer) { // The topmost selected layer, in stack order. let sel = &app.session.state.selected_layers; @@ -2630,12 +2658,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); - if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { - // A pick whip just put a reference in: editing goes on in the new text, - // the cursor after the reference. + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) { + // Asked for once (a pick whip put a reference in, or an Alt-click added the + // expression): editing starts in the expression as it is now. + ctx.data_mut(|d| d.remove::(expr_focus_id())); buf = ex.text.clone(); + let n = buf.chars().count(); + let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end))); let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after))))); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b))); st.store(&ctx, edit_id); ctx.memory_mut(|m| m.request_focus(edit_id)); } @@ -2733,6 +2764,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.modifiers.alt) { if resp.clicked() { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); + if prop.expr.is_none() { + // Adding one: its editor opens, comes into view and takes the + // keyboard with the new expression selected (#1072). + app.ui.timeline.expr_closed.remove(uid); + focus_expression(&ctx, *uid, [usize::MAX, 0]); + } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) { + // Removing it: nothing is left to take the keyboard. + ctx.data_mut(|d| d.remove::(expr_focus_id())); + } } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); @@ -3057,7 +3097,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { params["expression"] = json!(text); params["range"] = json!([a, b]); let after = text.chars().count().saturating_sub(b); - ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after)); + focus_expression(&ctx, src_prop, [after, after]); } actions.push(("prop.pickWhip".into(), params)); } diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..7bc5505ed 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,95 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// A solid named `name` with its Transform group open in the Timeline; returns the layer, its +/// Opacity uid and its Rotation uid. +fn open_transform(h: &mut Harness<'_, EffectcraftApp>, name: &str) -> (u64, u64, u64) { + let (layer, opacity, rotation, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + (layer, opacity, rotation) +} + +fn expression(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(prop).unwrap().expr.as_ref().map(|e| e.text.clone()) +} + +fn commit_expression(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: Alt-clicking a stopwatch adds an expression and its editor takes the keyboard at once, +/// the new expression selected, so typing replaces it (it took another click). That happens +/// once: a field clicked next keeps the keyboard, and removing the expression with another +/// Alt-click leaves nothing to take it. +#[test] +fn alt_clicking_a_stopwatch_focuses_the_new_expression() { + let (mut h, _) = harness(); + let (layer, opacity, rotation) = open_transform(&mut h, "Plate"); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let stopwatch = |h: &Harness<'_, EffectcraftApp>, prop: u64| center(h, &format!("timeline.prop.{prop}.stopwatch")); + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + assert!(expression(&h, layer, opacity).is_some(), "an expression was added"); + type_text(&mut h, "50"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("50"), "typing replaced the new expression"); + // A field clicked right after an Alt-click keeps the keyboard. + let at = stopwatch(&h, rotation); + click_with(&mut h, at, alt); + let added = expression(&h, layer, rotation).expect("an expression on Rotation"); + let p = center(&h, "timeline.timecode"); + click(&mut h, p, 2); + type_text(&mut h, "0:00:01:00"); + key(&mut h, Key::Enter); + let frame = h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time()); + assert_eq!((frame, expression(&h, layer, rotation)), (24, Some(added)), "the time took the typing, not the expression"); + // Alt-click again removes the expression; nothing takes the keyboard after it. + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + h.run_steps(5); + assert_eq!(expression(&h, layer, opacity), None); + assert!(!h.ctx.egui_wants_keyboard_input(), "nothing has the keyboard"); +} + +/// #1072: an expression added at the bottom of the Timeline brings its editor into view to take +/// the keyboard (its row is added below the last one shown). +#[test] +fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() { + let (mut h, _) = harness(); + let (layer, opacity, _) = open_transform(&mut h, "Bottom"); + for i in 0..40 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + h.run_steps(3); + // Scroll to the end (the layer is at the bottom of the stack), then until Opacity is the + // last row shown. + h.state_mut().ui.timeline.scroll_y = 1.0e6; + h.run_steps(3); + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let bottom = rows[1] + rows[3]; + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + h.state_mut().ui.timeline.scroll_y += row[1] + row[3] - bottom; + h.run_steps(3); + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + assert!((row[1] + row[3] - bottom).abs() < 1.0, "Opacity is at the bottom: {row:?}"); + let at = center(&h, &format!("timeline.prop.{opacity}.stopwatch")); + click_with(&mut h, at, Modifiers { alt: true, ..Modifiers::NONE }); + let editor = h.state().auto.find(&format!("timeline.prop.{opacity}.expression")).expect("the editor is drawn").rect; + assert!(editor[1] >= rows[1] - 0.5 && editor[1] + editor[3] <= bottom + 0.5, "the editor is in view: {editor:?} in {rows:?}"); + type_text(&mut h, "25"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25")); +} From 864b9e4ede6bde47e0577264df724e3517417d90 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:12:15 -0400 Subject: [PATCH 048/916] 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 049/916] 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 3a953859f227be26b85d6cb9c1f9d127fd9da6fc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:49 -0400 Subject: [PATCH 050/916] Ease Presets: ease the incoming, outgoing or both sides of selected keyframes (#1329) The panel eased only pairs of neighbouring selected keyframes, so a single selected key (or keys that aren't neighbours) changed nothing. An Ease selector (In, Out, In-Out; In-Out by default) now chooses the sides of each selected key to ease. In eases a key's incoming side with the curve's in side, Out its outgoing side with the curve's out side. A side is eased when it has a neighbouring key, selected or not; the neighbour is left as it is. Each side's speeds scale to the segment on that side, so a key between a +100/s and a -40/s segment gets each its own. The side not eased keeps playing as it did (easeSide, unchanged). Sides are measured before any key changes, and no undo step opens when no selected key has a segment on the chosen side; the status says which neighbour is missing. A selected pair in In-Out still gets the whole curve; its outer sides now ease too when they face an unselected key. From Keys still reads the curve of a selected pair. --- crates/script/src/tests_ease_presets.rs | 5 +- extensions/scriptui-panels/Ease Presets.jsx | 83 ++++++++++++++------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c803c27a5..1006d1bf1 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Ease In-Out")); - assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + // In-Out (the default) on every key: each pair gets the whole curve, as before. + assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}"); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); @@ -203,7 +204,7 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { s.execute("edit.deselectAll", json!({})).unwrap(); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); - assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it"); assert_eq!(s.history.undo.len(), steps); } diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index a08fdbcc2..0dd3f43ae 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -1,4 +1,4 @@ -// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// Ease Presets: easing curves kept by name and applied to the sides of selected keyframes. // // A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease // Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, @@ -12,6 +12,11 @@ // speeds are scaled to it: per dimension, along the motion path for spatial properties, or as // overall progress for shapes. // +// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out +// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a +// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the +// segment on that side. So a selected pair gets the whole curve, and a single key works too. +// // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", // "inSpeed"}}]}. @@ -98,9 +103,8 @@ // ------------------------------------------------------------------ keyframes - // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's - // index), by property. - function selectedPairs() { + // Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys). + function selectedKeyed() { var comp = app.project.activeItem; var out = []; if (!(comp instanceof CompItem)) return out; @@ -109,7 +113,17 @@ var p = props[i]; if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; var keys = p.selectedKeys; - for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + if (keys.length) out.push({ prop: p, keys: keys }); + } + return out; + } + + // Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property. + function selectedPairs() { + var keyed = selectedKeyed(), out = []; + for (var i = 0; i < keyed.length; i++) { + var keys = keyed[i].keys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] }); } return out; } @@ -175,29 +189,39 @@ } } - // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the - // number of pairs eased. - function applyCurve(c) { - var pairs = selectedPairs(), n = 0; - if (!pairs.length) return 0; + // The eases of one side with `speed` (relative to the segment's average) and `influence`. + function eases(s, speed, influence) { + var out = []; + for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence)); + return out; + } + + // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the + // number of keyframes eased. + function applyCurve(c, mode) { + var keyed = selectedKeyed(), sides = [], keys = 0; + var doIn = mode !== "Out", doOut = mode !== "In"; + // Every side and its eases first: the segments are measured before any key changes. + for (var i = 0; i < keyed.length; i++) { + var p = keyed[i].prop; + for (var j = 0; j < keyed[i].keys.length; j++) { + var k = keyed[i].keys[j], before = sides.length; + var s = doIn && k > 1 ? slopes(p, k - 1) : null; + if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + s = doOut && k < p.numKeys ? slopes(p, k) : null; + if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (sides.length > before) keys++; + } + } + if (!sides.length) return 0; app.beginUndoGroup("Apply Ease Preset"); try { - for (var i = 0; i < pairs.length; i++) { - var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); - if (!s) continue; - var outE = [], inE = []; - for (var d = 0; d < s.length; d++) { - outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); - inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); - } - easeSide(p, a, true, outE); - easeSide(p, a + 1, false, inE); - n++; - } + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return n; + return keys; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -288,6 +312,13 @@ var inInfluence = field(inRow, "In %", "inInfluence"); var inSpeed = field(inRow, "Speed", "inSpeed"); + var modeRow = ui.add("group"); + modeRow.add("statictext", undefined, "Ease"); + var MODES = ["In", "Out", "In-Out"]; + var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" }); + mode.selection = 2; + mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both"; + var actions = ui.add("group"); var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); @@ -346,8 +377,10 @@ } function apply(c, what) { - var n = applyCurve(c); - status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + var m = mode.selection ? mode.selection.text : "In-Out"; + var n = applyCurve(c, m); + var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; + status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; return n; } From af486dc44a86dd639ca65f1d465061f50add6ebb Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 051/916] Ease Presets: tests for easing single keyframes In, Out and In-Out (#1329) One middle key between unequal segments in each mode, with its neighbours, times, values and selection unchanged and one undo restoring it; first and last keys, keys that aren't neighbours and keys of several properties; no undo step when nothing can ease; a Hold and an auto-Bezier side kept as they play; per-dimension and motion-path speeds on Scale and Position; and the pair's outer sides under In-Out and Out. All but the unchanged API tests fail on main. --- crates/script/src/tests_ease_presets.rs | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 1006d1bf1..4d05d7c6f 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -208,6 +208,200 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert_eq!(s.history.undo.len(), steps); } +/// Select only the keys at `keys` (property path, time in seconds). +fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) { + s.execute("edit.deselectAll", json!({})).unwrap(); + let keys: Vec = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); +} + +/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply. +fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + set(s, win, "mode", json!(mode)); + click(s, win, "apply"); +} + +fn eased(e: Ease, speed: f64, influence: f64) -> bool { + close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9) +} + +/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both, +/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its +/// neighbours, key times, values or the selection; one undo step restores it. +#[test] +fn one_selected_keyframe_eases_its_in_out_or_both_sides() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + // In-Out is the default. + assert_eq!(control(&mut s, win, "mode")["selection"], json!([2])); + let before = prop(&s, l, "transform/opacity"); + for mode in ["In", "Out", "In-Out"] { + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + let selected = s.state.selected_keys.clone(); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]); + assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve")); + assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step"); + assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change"); + assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}"); + let k = &op.keys[1]; + let (ins, outs) = (mode != "Out", mode != "In"); + assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}"); + assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}"); + if ins { + assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease); + } else { + assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly"); + } + if outs { + assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease); + } else { + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone"); + } +} + +/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes +/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several +/// properties, each ease on their own. +#[test] +fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] { + select_keys(&mut s, l, &[("transform/opacity", t)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, c); + assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}")); + assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + } + // In-Out on the first key eases only its out side. + select_keys(&mut s, l, &[("transform/opacity", 0.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier)); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert_eq!(op.keys[1..], before.keys[1..]); + s.execute("edit.undo", json!({})).unwrap(); + // The first and last Opacity keys (not neighbours) and the last Rotation key. + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, "In-Out", c); + assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve"); + assert_eq!(s.history.undo.len(), steps + 1); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease); + assert_eq!(op.keys[1], before.keys[1], "the unselected middle key"); + let rot = prop(&s, l, "transform/rotation"); + assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease); + assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); +} + +/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides +/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only +/// out sides. +#[test] +fn a_selected_pair_eases_the_sides_its_mode_names() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6)); + assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease); + assert_eq!(op.keys[2], before.keys[2]); + s.execute("edit.undo", json!({})).unwrap(); + apply_typed(&mut s, win, "Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3)); + assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear)); +} + +/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an +/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too. +#[test] +fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType; + t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD); + t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + t.rotation.setTemporalAutoBezierAtKey(2, true);"#, + ); + let rot = prop(&s, l, "transform/rotation"); + let after: Vec = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect(); + let win = open_panel(&mut s); + set(&mut s, win, "mode", json!("In")); + select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold)); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease); + assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds"); + let eased_rot = prop(&s, l, "transform/rotation"); + assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease); + for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) { + assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}"); + } + assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); +} + +/// On one key, each side scales to its own segment: per dimension, and along the motion path. +#[test] +fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + // Scale: +100/−50 per second into the key, 0/+50 per second out of it. + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]); + // Position on a straight path: 100 px/s into the key, 50 px/s out of it. + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]); + apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve"); + let k = prop(&s, l, "transform/scale").keys[1].clone(); + assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease); + assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease); + let k = prop(&s, l, "transform/position").keys[1].clone(); + assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease); + assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease); + let pos = prop(&s, l, "transform/position"); + assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays"); +} + #[test] fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { let store = Arc::new(MemoryConfig::default()); From c0f919a8589ad49e6dd0603cd9e2ab5b5e35d240 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 052/916] Docs: plugins.md describes the Ease Presets In, Out and In-Out sides (#1329) --- docs/plugins.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index fc7c0b8ff..861739b8b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -103,17 +103,22 @@ name in your Scripts folders takes their place. ### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) -Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring -selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to the sides of selected +keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at rest), so one curve fits any duration and change of value. -* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected - property, in one undo step (per dimension, and along the motion path for spatial properties; - the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* Click a preset to apply it to the selected keyframes of each selected property, in one undo + step. **Ease** chooses the sides (#1329): **In** eases each key's incoming side with the curve's + in side, **Out** its outgoing side with the curve's out side, **In-Out** (the default) both. A + side is eased when it has a neighbouring keyframe, selected or not (the neighbour doesn't + change), with its speeds scaled to the segment on that side: per dimension, and along the + motion path for spatial properties. So a single keyframe works, a selected pair gets the whole + curve, and the first and last keys ease the side that has a segment. The side not eased keeps + playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and - click Apply, or click From Keys to read the curve between the first selected pair. + click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename and Delete act on the selected preset of your own. The twelve built-in presets can't change. * User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in From f62af4ca73bbb33a4d1e1ab5602cfac101e29683 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:55:43 +0200 Subject: [PATCH 053/916] Guide layers: hidden in nested comps, and in the Timeline context menu, as in After Effects Guide layers render only in their own comp's viewer: a nested renderer (precomp, collapsed precomp in 2D, Classic 3D and Advanced 3D, comps read by effects, aux passes, the GPU walk's resident precomps) now runs with guides off (Renderer::nested_opts), so a precomp's guide layers no longer show in the parent comp's viewer or in a "Current Settings" render. The Timeline layer and Composition viewer right-click menus (shared layer_menu) gain a Guide Layer item (a plain toggle, no check mark) acting on the selection, and guide layers show a guide mark before their name in the Timeline. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 +++- crates/render/src/tests_guides.rs | 108 ++++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/i18n/ui.rs | 7 ++ crates/ui-egui/src/panels/timeline.rs | 48 +++++++-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/tests/ui_guide_layers.rs | 129 ++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests_guides.rs create mode 100644 crates/ui-egui/tests/ui_guide_layers.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..45581d7f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -533,7 +533,9 @@ pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, pub motion_blur: bool, - /// Render guide layers (viewer only). + /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested + /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in + /// After Effects (see [`Renderer::nested_opts`]). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, @@ -737,9 +739,16 @@ impl<'a> Renderer<'a> { sub } + /// The render options one level down (precomps, collapsed precomps, comps read by effects): + /// guide layers render only in their own comp, never when it is nested. + fn nested_opts(&self) -> RenderOpts { + RenderOpts { guides: false, ..self.opts } + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { + opts: self.nested_opts(), depth: self.depth + 1, outer: None, opacity_mul: 1.0, @@ -830,7 +839,10 @@ impl<'a> Renderer<'a> { if let Some(slot) = slot { *slot = Some(link); } - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) + Some(( + Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, + nctx, + )) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -2388,6 +2400,8 @@ mod tests_footage_size; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_guides; +#[cfg(test)] mod tests_mask_blur; #[cfg(test)] mod tests_nested; diff --git a/crates/render/src/tests_guides.rs b/crates/render/src/tests_guides.rs new file mode 100644 index 000000000..cb9851250 --- /dev/null +++ b/crates/render/src/tests_guides.rs @@ -0,0 +1,108 @@ +//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is +//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + l.switches.three_d = three_d; + l +} + +#[derive(Clone, Copy, Debug)] +enum Nest { + /// A plain precomp layer (the nested comp rendered on its own, then composited). + Precomp, + /// Collapse Transformations on a 2D precomp layer. + Collapsed, + /// A collapsed 3D precomp in a Classic 3D comp. + Collapsed3d, + /// A collapsed 3D precomp in an Advanced 3D comp. + CollapsedAdvanced, +} + +/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the +/// same size, holding only Inner nested as `nest`. +fn scene(nest: Nest) -> (Project, ItemId, ItemId) { + let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d); + guide.switches.guide = true; + let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); + let mut inner = inner; + inner.layers = vec![guide, bg]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + if matches!(nest, Nest::CollapsedAdvanced) { + outer.renderer = effectcraft_project::Renderer::Advanced3D; + } + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.switches.collapse = !matches!(nest, Nest::Precomp); + pre.switches.three_d = three_d; + outer.layers = vec![pre]; + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, iid, oid) +} + +/// The centre pixel of `comp` rendered with or without guide layers. +fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] { + Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50) +} + +fn is_green(c: [f32; 4]) -> bool { + c[1] > 0.9 && c[0] < 0.1 +} + +fn is_red(c: [f32; 4]) -> bool { + c[0] > 0.9 && c[1] < 0.1 +} + +#[test] +fn guide_layer_renders_in_its_own_comp_with_guides_on() { + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, true); + assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}"); +} + +#[test] +fn final_renders_leave_guide_layers_out_by_default() { + assert!(!RenderOpts::default().guides); + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, false); + assert!(is_red(c), "guide layer left out of final renders: {c:?}"); +} + +#[test] +fn guide_layers_of_a_nested_comp_never_render_in_the_parent() { + for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] { + let (p, inner, outer) = scene(nest); + // The nested comp's own viewer still shows them… + let own = centre(&p, inner, true); + assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}"); + // …but not the parent's viewer (guides on) nor its final render. + for guides in [true, false] { + let c = centre(&p, outer, guides); + assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}"); + } + } +} + +#[test] +fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() { + // The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in + // final renders. + let (mut p, _, outer) = scene(Nest::Precomp); + p.comp_mut(outer).unwrap().layers[0].switches.guide = true; + assert!(is_red(centre(&p, outer, true))); + assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956a..f1f4c185f 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -879,7 +879,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) - || (!r.opts.guides && l.switches.guide) + || (!sub.opts.guides && l.switches.guide) { continue; } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32fa..ca7998fe0 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -503,7 +503,7 @@ fn collapsed_items<'a>( .layers .iter() .rev() - .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide)) .collect(); let mut items = Vec::new(); for (k, l) in nested.into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..64a1b6d1a 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), + ("Guide Layer", "ガイドレイヤー"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), ("Height:", "高さ:"), @@ -806,6 +807,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "列表"), ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), + ("Guide Layer", "参考线图层"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), ("Height:", "高度:"), @@ -1538,6 +1540,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "清單"), ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), + ("Guide Layer", "參考圖層"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), ("Height:", "高度:"), @@ -2273,6 +2276,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), + ("Guide Layer", "Допоміжний шар"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), ("Height:", "Висота:"), @@ -2823,6 +2827,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), ("Grid", "Grade"), + ("Guide Layer", "Camada de guia"), ("Guide Layers: {}", "Camadas de guia: {}"), ("Guides", "Guias"), ("Height:", "Altura:"), @@ -3274,6 +3279,7 @@ const CZECH: &[(&str, &str)] = &[ ("Freeze", "Zmrazit"), ("Grab bag", "Mozaika"), ("Grid", "Mřížka"), + ("Guide Layer", "Pomocná vrstva"), ("Guide Layers: {}", "Pomocné vrstvy: {}"), ("Guides", "Vodítka"), ("Height:", "Výška:"), @@ -4310,6 +4316,7 @@ mod tests { set_current("cs"); assert_eq!(tr("Composition Name:"), "Název kompozice:"); assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..d00dbd5e7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2042,7 +2042,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + // A guide layer carries a guide mark before its name (After Effects). + let name_x = if layer.switches.guide { + let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0)); + icons::paint(&lp, mark, Icon::Grid, t.text_dim); + app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer"); + cw.name + 50.0 + } else { + cw.name + 34.0 + }; + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2103,7 +2112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2324,7 +2333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the // drag starts and followed below the rows until the button is released. @@ -3857,12 +3866,14 @@ impl LayerMenuCaps { /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. +#[allow(clippy::too_many_arguments)] fn layer_context_menu( resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], caps: LayerMenuCaps, + auto: &mut crate::automation::Registry, actions: &mut Vec<(String, serde_json::Value)>, ) { let in_selection = selected.contains(&layer.id); @@ -3870,12 +3881,24 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } -/// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { +/// The layer switches the layer context menus toggle (After Effects lists them after Time): +/// `layer.setSwitch` name and label. +const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")]; + +/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation +/// registry and the id prefix of the menu's items. +pub(crate) fn layer_menu( + ui: &mut egui::Ui, + layers: &[u64], + caps: LayerMenuCaps, + auto: (&mut crate::automation::Registry, &str), + actions: &mut Vec<(String, serde_json::Value)>, +) { let Some(&id) = layers.first() else { return }; + let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); @@ -3921,6 +3944,18 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); + // A click toggles the switch on the target layers, as Layer ▸ Guide Layer does. + // (Pushed after the other items: `item` holds `actions` until its last use.) + let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new(); + for (switch, label) in MENU_SWITCHES { + let response = ui.button(crate::i18n::tr(label)); + auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label); + if response.clicked() { + switch_actions.push(("layer.select".into(), json!({"layers": layers}))); + switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch}))); + ui.close(); + } + } ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { @@ -3943,6 +3978,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); item(ui, "Delete", "edit.clear", json!({"layers": layers})); + actions.extend(switch_actions); } /// The layer context menu's Effects submenu: every effect from the registry grouped by category, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..324ed3dd8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1274,7 +1274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { caps }, ); - super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); + super::timeline::layer_menu(ui, &context_layers, caps, (&mut app.auto, "viewer.layerMenu"), &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/ui_guide_layers.rs new file mode 100644 index 000000000..37158658f --- /dev/null +++ b/crates/ui-egui/tests/ui_guide_layers.rs @@ -0,0 +1,129 @@ +//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item +//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's +//! name. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use serde_json::json; + +/// A 640×360 comp with two 200×200 solids, "Left" at (160, 180) and "Right" at (480, 180). +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, LayerId) { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Guides", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut solid = |name: &str, x: f64| { + let id = session.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [x, 180.0]})).unwrap(); + LayerId(id) + }; + let left = solid("Left", 160.0); + let right = solid("Right", 480.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + (h, left, right) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { + h.state().session.active_comp().unwrap().layer(id).unwrap().switches.guide +} + +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} + +/// The open menu's Guide Layer item carries no check mark (a plain toggle). +fn plain(h: &Harness<'_, EffectcraftApp>) -> bool { + !h.get_by_label_contains("Guide Layer").accesskit_node().label().unwrap_or_default().contains('✔') +} + +fn row(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Pos2 { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) +} + +/// The Guide Layer item of the open menu `prefix`. +fn guide_item(h: &Harness<'_, EffectcraftApp>, prefix: &str) -> Rect { + rect(h, &format!("{prefix}.switch.guide")) +} + +#[test] +fn timeline_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_none(), "no guide mark yet"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![left], "right-click selects the row"); + assert!(plain(&h), "no check mark"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left), "Guide Layer turned on"); + assert!(!guide(&h, right), "only the target layer"); + // The guide mark shows by the name. + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_some(), "guide mark by the name"); + // Again: off. Undo brings it back. + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert!(plain(&h), "no check mark on a guide layer either"); + snapshot(&mut h, "timeline-menu"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left), "Guide Layer turned off"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(guide(&h, left)); +} + +#[test] +fn timeline_layer_menu_guide_layer_acts_on_the_selection() { + let (mut h, left, right) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [left.0, right.0]})).unwrap(); + h.run_steps(3); + let p = row(&h, right); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left) && guide(&h, right), "both selected layers become guide layers"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left) && !guide(&h, right), "and back together"); +} + +#[test] +fn viewer_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let at = |x: f32| comp.min + vec2(x, 180.0) * k; + click(&mut h, at(480.0), PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![right], "right-click picks the layer under the pointer"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, right) && !guide(&h, left)); + // The Timeline and viewer menus share the item: the viewer menu turns it off again. + click(&mut h, at(480.0), PointerButton::Secondary); + assert!(plain(&h), "no check mark"); + snapshot(&mut h, "viewer-menu"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, right)); +} From 9b74dba2a89d647be8b7300c91d4278b4378bbc7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 054/916] Ease Presets: skip roving keyframes and say so, rather than reporting them eased (#1329) A roving key passes speed straight through: the engine keeps both its sides linear whenever the keys are edited (retime_roving), so an ease set on one never plays. The panel still counted it as eased, so a selected roving key showed "Eased 1 keyframe(s)" and stayed linear. The previous pair-only panel did the same to a pair holding a roving key. Selected roving keys are now left out of the work and counted. The status names how many were skipped and that turning off Roving lets them ease; when nothing else was selected, nothing changes and no undo step opens. Fixed keys next to a roving key still ease. --- crates/script/src/tests_ease_presets.rs | 35 +++++++++++++++++++++ extensions/scriptui-panels/Ease Presets.jsx | 22 ++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 4d05d7c6f..c9871d14a 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -378,6 +378,41 @@ fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); } +/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set +/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased. +#[test] +fn roving_keyframes_are_skipped_and_reported() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)"); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[1].roving); + let roving_at = pos.keys[1].time.seconds(); + let win = open_panel(&mut s); + // Only the roving key: nothing changes, no undo step. + select_keys(&mut s, l, &[("transform/position", roving_at)]); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(prop(&s, l, "transform/position").keys, pos.keys); + // With an ordinary key: that one is eased, the roving one is counted as skipped. + select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]); + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!( + status(&mut s, win), + "Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them" + ); + let k = &prop(&s, l, "transform/position").keys[1]; + assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}"); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 0dd3f43ae..6f6eadae5 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -16,6 +16,8 @@ // its outgoing side with the curve's out side, In-Out both: a side is eased when it has a // neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the // segment on that side. So a selected pair gets the whole curve, and a single key works too. +// Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease +// set on them wouldn't play. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -197,16 +199,20 @@ } // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in - // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the - // number of keyframes eased. + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns + // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. function applyCurve(c, mode) { - var keyed = selectedKeyed(), sides = [], keys = 0; + var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. for (var i = 0; i < keyed.length; i++) { var p = keyed[i].prop; for (var j = 0; j < keyed[i].keys.length; j++) { var k = keyed[i].keys[j], before = sides.length; + if (p.keyRoving(k)) { + roving++; + continue; + } var s = doIn && k > 1 ? slopes(p, k - 1) : null; if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; @@ -214,14 +220,14 @@ if (sides.length > before) keys++; } } - if (!sides.length) return 0; + if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return keys; + return { eased: keys, roving: roving }; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -378,9 +384,11 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var n = applyCurve(c, m); + var r = applyCurve(c, m), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; - status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; + var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; + if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); + else status.text = skipped ? "Nothing eased: " + skipped : "Select keyframes with another keyframe " + side; return n; } From 4dddb34cbab47076a5e1f01e8bc90db526b4c28c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 055/916] Ease Presets: tests for Out alone through undo and redo, and a single key on a curved path (#1329) Out on an auto-Bezier key leaves its in side playing the same values, and undo and redo restore each state. On a curved Position path with unequal segment lengths and durations, In sets the key's speed from the incoming path length, playback arrives faster than a constant speed, and the out side plays as before. --- crates/script/src/tests_ease_presets.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c9871d14a..009cc59c6 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -413,6 +413,65 @@ fn roving_keyframes_are_skipped_and_reported() { assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); } +/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state. +#[test] +fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType; + r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + r.setTemporalAutoBezierAtKey(2, true);"#, + ); + let before = prop(&s, l, "transform/rotation"); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/rotation", 1.0)]); + apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]); + let after = prop(&s, l, "transform/rotation"); + assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease); + for t in [0.25, 0.5, 0.75] { + assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}"); + } + assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys); +} + +/// On a curved path, a single key's in and out speeds follow the length of the path on each side +/// (unequal lengths and durations), and the side not eased plays as it did. +#[test] +fn a_single_key_on_a_curved_path_eases_along_each_side() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]); + let before = prop(&s, l, "transform/position"); + let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1)); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/position", 1.0)]); + apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]); + let pos = prop(&s, l, "transform/position"); + let k = &pos.keys[1]; + // In speed: twice the incoming segment's average (its path length over 1 s). + assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease); + // The out side (2 s along a longer path) plays as before. + for t in [1.5, 2.0, 2.5] { + let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2()); + assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}"); + } + // Playback on the in side: arriving at twice the average speed, the last tenth of a second + // covers more of the path than a constant speed would (len_in / 10). + let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2()); + let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt(); + assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}"); + assert!(len_out > len_in, "{len_out} {len_in}"); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { From a37357f082a77df31d8c438cb2e2f9560142ee50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 056/916] Docs: plugins.md notes that Ease Presets skips roving keyframes (#1329) --- docs/plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 861739b8b..a688a89b0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -116,7 +116,9 @@ rest), so one curve fits any duration and change of value. change), with its speeds scaled to the segment on that side: per dimension, and along the motion path for spatial properties. So a single keyframe works, a selected pair gets the whole curve, and the first and last keys ease the side that has a segment. The side not eased keeps - playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. + playing as it did (a Hold stays a Hold). Roving keyframes are skipped, and the status says how + many: they pass speed straight through, so an ease on one wouldn't play (turn off Roving to + ease it). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename From 8408af323a7526a484cca3f238adbc3e50c09562 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 14:21:14 -0600 Subject: [PATCH 057/916] 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 058/916] 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 da73df4b2eba41f2882a0a2eb283e6f770c6eb51 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:03:53 -0600 Subject: [PATCH 059/916] Script sockets: per-socket locks, a write deadline, and close interrupts (#1248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every socket operation locked one global table and held it through the blocking write_all/flush, and streams had no write timeout (accepted ones were explicitly blocking). A peer that stopped reading kept the script blocked for good, and every other socket call, close included, waited behind it. - The table maps handles to Arc, each with its own Mutex. The table is locked only to look a socket up, add or remove one, never during network I/O, so one stalled socket doesn't hold up the others. - `write` sets a write timeout from the socket's `timeout` (10 s by default, as reads) and reports "write timed out … the other side isn't reading" instead of blocking. Failures to set the read or write timeout, or to make an accepted stream blocking, are reported instead of ignored. - `close` removes the socket and shuts its stream down through a second handle, so a call blocked on it fails at once. Tests (socket.rs, native only): a 64 MB write to a loopback peer that never reads fails with "timed out" after a 1 s timeout; while one socket is blocked writing, another answers at once, and closing the blocked one returns at once and ends its write. Both fail on main (the write never gives up; the second socket waits on the table lock). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/socket.rs | 168 +++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6ac0d41d2..4030332de 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -50,8 +50,8 @@ mod imp { mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; - use std::net::{TcpListener, TcpStream, ToSocketAddrs}; - use std::sync::Mutex; + use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use super::*; @@ -61,9 +61,18 @@ mod imp { Listener(TcpListener), } + /// One open socket. Reading, writing and polling lock only it, never the whole table, so a + /// socket blocked on a slow peer doesn't hold up the others (#1248). + struct Entry { + sock: Mutex, + /// A second handle on a stream, so `close` can shut it down while another call is + /// blocked reading or writing it. + wake: Option, + } + struct Table { next: u32, - socks: BTreeMap, + socks: BTreeMap>, } fn table() -> &'static Mutex { @@ -72,13 +81,31 @@ mod imp { } fn add(s: Sock) -> u32 { + let wake = match &s { + Sock::Stream { s, .. } => s.try_clone().ok(), + Sock::Listener(_) => None, + }; + let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; - t.socks.insert(id, s); + t.socks.insert(id, entry); id } + /// The socket `id`, if open. The table is locked only for the lookup. + fn get(id: u32) -> Option> { + table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned() + } + + /// Forget socket `id`; a call blocked on it fails at once. + fn close(id: u32) { + let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); + if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { + let _ = w.shutdown(Shutdown::Both); + } + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -98,8 +125,9 @@ mod imp { if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } } - /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. - fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when + /// the read deadline can't be set. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> { let end = Instant::now() + limit; let mut chunk = [0u8; 4096]; while !done(buf) && !*eof { @@ -107,7 +135,7 @@ mod imp { if left.is_zero() { break; } - let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + s.set_read_timeout(Some(left.max(Duration::from_millis(1))))?; match s.read(&mut chunk) { Ok(0) => *eof = true, Ok(n) => buf.extend_from_slice(&chunk[..n]), @@ -119,6 +147,7 @@ mod imp { Err(_) => *eof = true, } } + Ok(()) } pub(super) fn run(op: &str, id: u32, p: &J) -> J { @@ -156,29 +185,27 @@ mod imp { Err(e) => err(format!("cannot listen on port {port}: {e}")), } } + "close" => { + close(id); + json!({}) + } _ => { - let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); - if op == "close" { - t.socks.remove(&id); - return json!({}); - } - let Some(sock) = t.socks.get_mut(&id) else { + let Some(entry) = get(id) else { return match op { "connected" => json!({"value": false}), "eof" => json!({"value": true}), _ => err("the socket is not open"), }; }; - match (op, sock) { + let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { - let _ = s.set_nonblocking(false); + if let Err(e) = s.set_nonblocking(false) { + return err(format!("cannot use the connection from {peer}: {e}")); + } let host = peer.to_string(); - let new = Sock::Stream { s, buf: vec![], eof: false }; - let nid = t.next; - t.next += 1; - t.socks.insert(nid, new); - json!({"id": nid, "host": host}) + json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "host": host}) } Err(_) => json!({"id": null}), }, @@ -202,19 +229,25 @@ mod imp { let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); let limit = timeout(p); if count < 0 { - fill(s, buf, eof, limit, |_| false); + if let Err(e) = fill(s, buf, eof, limit, |_| false) { + return err(format!("cannot set the read timeout: {e}")); + } let all = std::mem::take(buf); json!({"data": decode(&all, utf8)}) } else { let n = count as usize; - fill(s, buf, eof, limit, |b| b.len() >= n); + if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) { + return err(format!("cannot set the read timeout: {e}")); + } let take = n.min(buf.len()); let out: Vec = buf.drain(..take).collect(); json!({"data": decode(&out, utf8)}) } } ("readln", Sock::Stream { s, buf, eof }) => { - fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) { + return err(format!("cannot set the read timeout: {e}")); + } let line: Vec = match buf.iter().position(|&c| c == b'\n') { Some(i) => { let mut l: Vec = buf.drain(..=i).collect(); @@ -229,9 +262,19 @@ mod imp { json!({"data": decode(&line, utf8)}) } ("write", Sock::Stream { s, .. }) => { + // A peer that stops reading fills the buffers: give up after `timeout` + // instead of blocking the script for good (#1248). + let limit = timeout(p).max(Duration::from_millis(1)); + if let Err(e) = s.set_write_timeout(Some(limit)) { + return err(format!("cannot set the write timeout: {e}")); + } let data = p.get("data").and_then(J::as_str).unwrap_or(""); match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { Ok(()) => json!({"ok": true}), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( + "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", + limit.as_secs_f64() + )), Err(e) => err(e), } } @@ -242,3 +285,82 @@ mod imp { } } } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + use serde_json::{Value as J, json}; + + use super::imp::run; + + /// A loopback peer that accepts one connection and never reads from it; returns its + /// `host:port` and the accepted stream's keeper (drop it to let the connection go). + fn stalled_peer() -> (String, mpsc::Sender<()>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let host = l.local_addr().unwrap().to_string(); + let (stop, wait) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let (_s, _): (TcpStream, _) = l.accept().unwrap(); + let _ = wait.recv_timeout(Duration::from_secs(120)); + }); + (host, stop) + } + + fn open(host: &str) -> u32 { + let r = run("open", 0, &json!({"host": host, "timeout": 5})); + r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 + } + + /// More than the loopback buffers hold, so a peer that doesn't read stops the write. + fn big() -> String { + "x".repeat(64 * 1024 * 1024) + } + + /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. + fn within(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(limit).ok() + } + + #[test] + fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { + // #1248: it used to block for good. + let (host, _keep) = stalled_peer(); + let id = open(&host); + let data = big(); + let t0 = Instant::now(); + let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); + assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + run("close", id, &J::Null); + } + + #[test] + fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { + let (host_a, _keep_a) = stalled_peer(); + let (host_b, _keep_b) = stalled_peer(); + let (a, b) = (open(&host_a), open(&host_b)); + // A write on A that would wait a minute. + let data = big(); + let (tx, blocked) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); + }); + std::thread::sleep(Duration::from_millis(500)); + // Socket B answers meanwhile (the table isn't locked by A's write). + let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); + assert_eq!(r["value"], true, "{r}"); + // Closing A returns at once and ends A's write. + within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked"); + let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed"); + assert!(r.get("error").is_some(), "{r}"); + run("close", b, &J::Null); + } +} From b26a340a7e653cd2e0e70408a341e1ebbe5bc67d Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:26:42 -0600 Subject: [PATCH 060/916] Project: comp_contains walks precomps with a visited set, not recursion (#1237) Project::comp_contains recursed through precomp layers with no visited set or depth limit. A damaged project with a precomp cycle that doesn't pass the queried comp (A and B nesting each other, C unrelated) recursed until the stack ran out, which aborts the process ("stack overflow, aborting"): it can't be caught as a command error, so unsaved work is lost. layer.addItem checks it before adding a precomp, and the renderer calls it for every nested comp and its audio. A long chain used the stack the same way, and comps shared by many paths were walked once per path (exponential for nested diamonds). It is now an explicit stack with a visited set: same answers (a comp contains itself; a target reached through a cycle is found), each comp visited once, no recursion. The renderer already skips a precomp that contains the comp being rendered, so a cyclic project now renders instead of crashing; nothing is repaired on load. Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_ precomps (cycle with an unrelated target, self-nesting, a target through a cycle, 40 levels of diamonds, a 20 000-comp chain); engine a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_ out_of_stack (file.open, layer.addItem both ways, render). On main the engine test aborts with a stack overflow. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_limits.rs | 35 ++++++++++++++++++++ crates/project/src/lib.rs | 25 ++++++++++++-- crates/project/src/tests.rs | 55 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs index 68f2c04c0..524719bbf 100644 --- a/crates/engine/src/tests_limits.rs +++ b/crates/engine/src/tests_limits.rs @@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() { let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap(); assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } + +#[test] +fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { + // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A + // contains C walked the A ⇄ B cycle until the stack ran out (#1237). + let mut s = Session::default(); + let mut ids = vec![]; + for name in ["A", "B", "C"] { + s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + ids.push(s.active_comp_id().unwrap()); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + { + let proj = std::sync::Arc::make_mut(&mut s.project); + for (outer, inner) in [(a, b), (b, a)] { + let comp = proj.comp(outer).unwrap().clone(); + let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None); + let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(cc).layers.push(l); + } + } + let path = tmp("cycle.ecproj"); + std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + t.open_comp(c); + // Adding A into C asks whether A contains C: it doesn't, so the layer goes in. + let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap(); + assert!(r["layer"].as_u64().is_some(), "{r}"); + // Adding C into A would nest C in itself through A: refused, as before. + assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err()); + // C renders: inside A the renderer skips B's copy of A instead of looping. + let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (32, 32)); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..27f8870d4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1466,11 +1466,32 @@ impl Project { } /// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles). pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool { + // A walk with a stack and a visited set rather than recursion (#1237): a damaged project + // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long + // chain must not use up the call stack, and comps shared by many paths are visited once + // (recursion re-walked them for every path). if outer == inner { return true; } - let Some(c) = self.comp(outer) else { return false }; - c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner))) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![outer]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item } = l.source { + if item == inner { + return true; + } + if !seen.contains(&item) { + stack.push(item); + } + } + } + } + false } /// Make sure `next_id` exceeds every id in the project (after loading or merging). pub fn fix_next_id(&mut self) { diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c19d81ee2..508cb743d 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -206,3 +206,58 @@ fn item_users_count_layers_in_every_comp() { assert!(p.item_users(unused).is_empty()); assert!(p.item_users(main).is_empty()); } + +/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a` +/// (indexes into the returned ids). +fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) { + let mut p = Project::default(); + let ids: Vec = (0..n) + .map(|i| { + p.add_item( + &format!("C{i}"), + effectcraft_color::Label::None, + None, + ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()), + ) + }) + .collect(); + for &(a, b) in links { + let outer = p.comp(ids[a]).unwrap().clone(); + let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None); + let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(c).layers.push(l); + } + (p, ids) +} + +#[test] +fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { + // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237). + let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]); + assert!(!p.comp_contains(c[0], c[2])); + assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0])); + // A comp nesting itself. + let (p, c) = comp_graph(2, &[(0, 0)]); + assert!(!p.comp_contains(c[0], c[1])); + assert!(p.comp_contains(c[0], c[0])); + // A target reached through a cycle. + let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]); + assert!(p.comp_contains(c[0], c[2])); + assert!(!p.comp_contains(c[2], c[0])); + // 40 levels of diamonds: 2^40 paths, each comp visited once. + let mut links = vec![]; + for i in 0..40 { + let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3); + links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]); + } + let (p, c) = comp_graph(121, &links); + let t0 = std::time::Instant::now(); + assert!(p.comp_contains(c[0], c[120])); + assert!(!p.comp_contains(c[120], c[0])); + assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed()); + // A chain 20 000 comps deep (a deep recursion's stack frames). + let n = 20_000; + let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>()); + assert!(p.comp_contains(c[0], c[n - 1])); + assert!(!p.comp_contains(c[n - 1], c[0])); +} From 79ebd019352508b0b461b557dc5110d328f61839 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:59:08 -0600 Subject: [PATCH 061/916] svg: bound expansion by an element budget and skip reference cycles (#1236) Depth-limited expansion still let a few kilobytes of branching references expand to 2^N elements. The parser now counts every element it converts, copies included, against a budget of max(500 000, twice the file's own elements) and fails the import with Error::Xml once it is spent. Targets currently being expanded are tracked, so mutual references stop at the first repeat instead of nesting to the depth limit. Fixes #1236 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/svg/src/lib.rs | 26 +++++++++++++++--- crates/svg/src/tests.rs | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814bf..b5ddd98e6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -486,6 +486,12 @@ struct Parser<'a, 'i> { vw: f64, vh: f64, skipped: Vec, + /// Elements converted so far, `` copies included, and how many may be (#1236). + work: usize, + budget: usize, + /// The `` targets being expanded, outermost first: a reference back into one is a + /// cycle and is skipped. + using: Vec, } fn is_svg_ns(n: &roxmltree::Node) -> bool { @@ -748,9 +754,12 @@ impl<'a, 'i> Parser<'a, 'i> { /// Convert an element (and its subtree) into nodes appended to `out`. fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec, depth: usize) { - if depth > 64 || !is_svg_ns(&n) { + if depth > 64 || !is_svg_ns(&n) || self.work > self.budget { return; } + // Every element converted counts, each `` copy again: a few references to groups + // that reference each other several times can multiply into billions (#1236). + self.work += 1; let tag = n.tag_name().name(); match tag { "defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker" @@ -816,9 +825,10 @@ impl<'a, 'i> Parser<'a, 'i> { } "use" => { let Some(target) = self.href(n) else { return }; - if target == n || n.ancestors().any(|a| a == target) { + if target == n || n.ancestors().any(|a| a == target) || self.using.contains(&target.id()) { return; } + self.using.push(target.id()); let x = self.attr_len(n, "x", 'x', &st); let y = self.attr_len(n, "y", 'y', &st); transform *= Affine::translate((x, y)); @@ -847,6 +857,7 @@ impl<'a, 'i> Parser<'a, 'i> { } else { self.node(target, &st, &mut children, depth + 1); } + self.using.pop(); out.push(Node::Group(Group { name: Self::element_name(n), transform, @@ -1048,6 +1059,9 @@ pub fn parse_transform(s: &str) -> Affine { /// 2 MiB thread's stack (aborting the app) at a few thousand levels; real documents stay far /// below this. const MAX_XML_DEPTH: usize = 256; +/// The most elements an import converts, `` copies included, unless the file itself has +/// more than half as many (#1236). +const MAX_EXPANDED_ELEMENTS: usize = 500_000; /// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing /// instructions, declarations and quoted attribute values). @@ -1121,7 +1135,9 @@ pub fn parse(bytes: &[u8]) -> Result { } } let vb = viewbox(root); - let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] }; + // Room for generous reuse, and always for every element of the file itself. + let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2)); + let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { match root.attribute(name) { @@ -1157,6 +1173,10 @@ pub fn parse(bytes: &[u8]) -> Result { for c in root.children() { p.node(c, &rst, &mut children, 1); } + if p.work > p.budget { + // Never a silently partial drawing. + return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); + } let transform = viewbox_transform(root, dw, dh); Ok(Doc { width: dw, diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index ed1ea873e..93db715c1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -217,3 +217,63 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { assert_eq!(crate::xml_depth(&s), 2); assert!(crate::parse(s.as_bytes()).is_ok()); } + +/// How deep `g`'s groups nest, and how many shapes it holds. +fn depth_and_shapes(g: &Group) -> (usize, usize) { + let mut depth = 0; + let mut shapes = 0; + for c in &g.children { + match c { + Node::Group(sub) => { + let (d, s) = depth_and_shapes(sub); + depth = depth.max(d + 1); + shapes += s; + } + Node::Shape(_) => shapes += 1, + Node::Image(_) => {} + } + } + (depth, shapes) +} + +#[test] +fn branching_use_references_stop_at_the_expansion_budget() { + // Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a + // file of a few kilobytes (#1236). + let mut defs = String::from(r#""#); + for i in 1..=30 { + defs += &format!(r##""##, p = i - 1); + } + let svg = format!(r##"{defs}"##); + let t0 = std::time::Instant::now(); + let e = parse(svg.as_bytes()).unwrap_err(); + assert!(e.to_string().contains("expand to more than"), "{e}"); + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); + // Below the budget the same pattern imports whole: 2^10 rectangles. + let small = svg.replace(r##""##, r##""##); + let doc = parse(small.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 1024); +} + +#[test] +fn mutually_referencing_uses_are_cut_at_the_first_repeat() { + // A uses B and B uses A, neither inside the other: once each, not down to the depth limit. + let svg = r##" + + + "##; + let doc = parse(svg.as_bytes()).unwrap(); + let (depth, shapes) = depth_and_shapes(&doc.root); + assert_eq!(shapes, 2, "a's and b's rectangle, once each"); + assert!(depth <= 6, "{depth}"); +} + +#[test] +fn a_symbol_used_a_thousand_times_still_imports() { + let uses: String = (0..1000).map(|i| format!(r##""##)).collect(); + let svg = format!( + r##"{uses}"## + ); + let doc = parse(svg.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 3000); +} From 3d45046132aa841fc768fde3d8f7b59dc4e23f7f Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:35:25 -0600 Subject: [PATCH 062/916] model: validate glTF accessor spans before allocating, with checked arithmetic (#1235) Accessor counts sized the f32/u32 output before the buffer view was looked at, and offsets, strides and count * components used unchecked arithmetic, so a few hundred bytes of JSON could request a multi-gigabyte allocation (aborting the process) or overflow. - Doc::layout checks count * components, adds the view and accessor offsets, rejects a byteStride smaller than an element, and checks that the last element ends inside the buffer, all with checked arithmetic, before floats() or ints() allocate anything. - Accessors without a buffer view (all zeros) are capped at 2^24 components. - idx() and node index lists use usize::try_from instead of 'as usize'; an image bufferView's end and a GLB chunk's end are computed with checked_add. Fixes #1235 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/gltf.rs | 87 +++++++++++++++++++++++++++++---------- crates/model/src/tests.rs | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 20578d4f4..e17d014f6 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { while o + 8 <= total { let len = u32_at(o).unwrap_or(0) as usize; let ty = u32_at(o + 4).unwrap_or(0); - let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?; + let past_end = || perr("GLB chunk runs past the end of the file"); + let end = (o + 8).checked_add(len).ok_or_else(past_end)?; + let data = b.get(o + 8..end).ok_or_else(past_end)?; match ty { CHUNK_JSON if json.is_none() => json = Some(data), CHUNK_BIN if bin.is_none() => bin = Some(data), _ => {} } - o += 8 + len; + o = end; } Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin)) } @@ -115,8 +117,25 @@ struct Doc<'a> { fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) } +/// A non-negative integer field; one too large for `usize` counts as absent (#1235). fn idx(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_u64).map(|x| x as usize) + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} +/// Node indices in an array field (out-of-range ones are dropped). +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). +const MAX_ZERO_COMPONENTS: usize = 1 << 24; + +/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` +/// apart from `base`, all checked to be inside `buf`. +struct Span<'b> { + buf: &'b [u8], + base: usize, + stride: usize, } fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(Value::as_f64).unwrap_or(d) @@ -132,6 +151,39 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { } 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 + /// accessor without a buffer view, whose components are all zero. + fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option>)> { + let count = match acc.get("count") { + None => 0, + Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?, + }; + 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})"))); + } + 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 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"))); + } + if count > 0 { + let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?; + if end > buf.len() { + return Err(past()); + } + } + Ok((count, total, Some(Span { buf, base, stride }))) + } + /// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1). fn floats(&self, a: usize) -> Result<(Vec, usize)> { let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; @@ -145,7 +197,6 @@ impl Doc<'_> { "MAT4" => 16, t => return Err(perr(format!("accessor type `{t}`"))), }; - let count = idx(acc, "count").unwrap_or(0); let ct = idx(acc, "componentType").unwrap_or(5126); let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); let csize = match ct { @@ -154,17 +205,13 @@ impl Doc<'_> { 5125 | 5126 => 4, _ => return Err(perr(format!("componentType {ct}"))), }; - let mut out = vec![0.0f32; count * n]; - let Some(bv) = idx(acc, "bufferView") else { + let (count, total, span) = self.layout(acc, n, csize)?; + let mut out = vec![0.0f32; total]; + let Some(Span { buf, base, stride }) = span else { // No buffer view: all zeros (sparse substitution is not supported). return Ok((out, n)); }; - 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 base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let elem = csize * n; // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -207,18 +254,14 @@ impl Doc<'_> { "VEC2" => 2, _ => 1, }; - let count = idx(acc, "count").unwrap_or(0); let csize = match ct { 5120 | 5121 => 1, 5122 | 5123 => 2, _ => 4, }; - let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) }; - 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 base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n); - let mut out = Vec::with_capacity(count * n); + let (count, total, span) = self.layout(acc, n, csize)?; + let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) }; + let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< arr(&j, "bufferViews").get(bv).and_then(|v| { let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec) + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) }) } else { None @@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< skin: idx(n, "skin"), camera: idx(n, "camera"), light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), - children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + children: indices(n, "children"), }); } let scene = idx(&j, "scene").unwrap_or(0); let roots: Vec = match arr(&j, "scenes").get(scene) { - Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + Some(s) => indices(s, "nodes"), None => { // No scene: every node that is nobody's child. let mut child = vec![false; nodes.len()]; @@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut skins = vec![]; for s in arr(&j, "skins") { - let joints: Vec = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(); + let joints: Vec = indices(s, "joints"); let inverse_bind = match idx(s, "inverseBindMatrices") { Some(a) => { let (f, _) = d.floats(a)?; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 9206ef83f..7c98c1ae7 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() { let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); } + +/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes. +fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String { + let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3]; + format!( + r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}], + "meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}], + "buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}], + "bufferViews": [{views}], "accessors": [{accessors}]}}"# + ) +} + +#[test] +fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { + // Untrusted counts, offsets and strides must fail the import, not allocate from the count or + // overflow (#1235). + let view = r#"{"buffer": 0, "byteLength": 36}"#; + let cases = [ + // A count far beyond the 36-byte buffer. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), + // count * components overflows. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), + // Offsets that overflow when added. + ( + r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, + r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#, + "past its buffer", + ), + // A stride that overflows with the count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), + // A stride smaller than an element would re-read the same bytes for a huge count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), + // No buffer view: nothing backs the count, so it is capped. + (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), + ]; + let t0 = std::time::Instant::now(); + for (acc, v, want) in cases { + let doc = gltf_doc(acc, v, 36); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported")); + assert!(e.to_string().contains(want), "{acc}: {e}"); + } + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); +} + +#[test] +fn gltf_integer_accessors_are_checked_too() { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#); + for idx in [ + r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#, + ] { + let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#)); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}"); + } +} + +#[test] +fn gltf_strided_and_buffer_less_accessors_still_import() { + // Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero + // accessor without a view. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 3); + let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 6); + // One byte short of the third element is an error. + 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()); +} From 4b475f57815e3743c0a44db95d7410fe4150ce44 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:44:27 -0600 Subject: [PATCH 063/916] model: budget the memory an FBX import keeps, and share instanced meshes (#1234) The 1 GiB array limit counted wire bytes, but arrays are kept as f64/i64, so boolean arrays grew 8x and 32-bit arrays 2x past it, and nothing bounded the total across arrays. Every Model linked to a Geometry also built its own copy of the mesh. - A per-import Budget (2 GiB) is charged 8 bytes per array element before the array is decompressed or widened, ASCII files are charged for their parsed values, and each built mesh is charged for its vertex and index data. Going past it fails the import with a clear error instead of an allocation abort. parse_within/read_within take a lower limit for tests. - Models instancing the same geometry with the same materials share one mesh; a different material mapping still gets its own. Fixes #1234 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/fbx.rs | 102 ++++++++++++++++++++++++++++++---- crates/model/src/fbx_tests.rs | 84 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index e2b9e1bd0..8a3fd9f40 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; const MAX_DEPTH: usize = 64; /// Largest decoded array accepted (bytes). const MAX_ARRAY_BYTES: usize = 1 << 30; +/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the +/// wire type) plus the meshes built from them (#1234). +const MAX_IMPORT_BYTES: usize = 1 << 31; + +/// What is left of an import's memory budget. +struct Budget(std::cell::Cell); + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget(std::cell::Cell::new(bytes)) + } + /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before + /// anything that large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.0.get(); + match bytes.filter(|&b| b <= left) { + Some(b) => { + self.0.set(left - b); + Ok(()) + } + None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), + } + } +} + +/// Bytes a mesh keeps. +fn mesh_bytes(m: &Mesh) -> Option { + m.primitives.iter().try_fold(0usize, |acc, p| { + let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; + let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; + acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) + }) +} + +/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as +/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). +fn value_bytes(nodes: &[FbxNode]) -> Option { + nodes.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} /// One property value of an FBX node. #[derive(Clone, Debug, PartialEq)] @@ -126,7 +177,17 @@ impl FbxNode { /// Read an FBX file (binary or ASCII) into its top-level nodes and the file version /// (e.g. 7400). pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } + read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) +} + +fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { + read_binary(bytes, budget) + } else { + let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&nodes), "its values")?; + Ok((version, nodes)) + } } // --------------------------------------------------------------------------------------------- @@ -136,6 +197,7 @@ struct Bin<'a> { d: &'a [u8], /// 64-bit record offsets (version 7500 and later). wide: bool, + budget: &'a Budget, } impl Bin<'_> { @@ -207,7 +269,7 @@ impl Bin<'_> { } fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; let t = sub.arr::<1>(pos)?[0]; Ok(match t { b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), @@ -234,6 +296,9 @@ impl Bin<'_> { _ => 1, }; let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the wire bytes. + sub.budget.take(len.checked_mul(8), "an array")?; let data = sub.take(pos, clen)?; let raw = match enc { 0 => data.to_vec(), @@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String { } } -fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500 }; + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { match bin.record(&mut pos, 0) { @@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str { /// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let (version, doc) = read(bytes)?; + parse_within(bytes, resolve, MAX_IMPORT_BYTES) +} + +/// [`parse`] keeping at most `limit` bytes of arrays and meshes. +fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { + let budget = Budget::new(limit); + let (version, doc) = read_within(bytes, &budget)?; if version != 0 && version < 7000 { return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); } @@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< Some(materials.len() - 1) }; - // One mesh per Model with Mesh geometry (material slots are per Model). + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the + // same geometry with the same materials share one mesh rather than each building a copy. let mut meshes: Vec = vec![]; + let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); for &id in &model_ids { let Some(&ni) = node_of.get(&id) else { continue }; let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; let Some(geom) = by_id.get(&gid) else { continue }; let mats: Vec = links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - meshes.push(mesh); - let mesh_index = meshes.len() - 1; + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(&i) => i, + None => { + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + meshes.push(mesh); + mesh_of.insert((gid, mats), meshes.len() - 1); + meshes.len() - 1 + } + }; let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); match geo { Some(matrix) => { diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs index 3cd8ba5a1..1ba1978ba 100644 --- a/crates/model/src/fbx_tests.rs +++ b/crates/model/src/fbx_tests.rs @@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() { assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); } + +/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn fbx_arrays_are_charged_for_the_memory_they_keep() { + // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: + // the budget counts the eight (#1234). + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); + let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); + assert!(e.to_string().contains("memory limit"), "{e}"); + // 32-bit floats widen to f64 the same way. + assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); + // Several arrays each within the budget but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); + assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); + // ASCII values count too. + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + ASCII + .replace( + "\tMaterial: 400,", + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,", + ) + .replace( + "\tC: \"OO\",500,300\n", + "\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn fbx_models_instancing_one_geometry_share_its_mesh() { + let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + // A budget with room for one copy of the geometry's mesh, but not three, still imports. + let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); + let mesh = mesh_bytes(&one.meshes[0]).unwrap(); + let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +} From 7b0771fe5acd5f69e8e6fe34722a88db2f09ddc3 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 20:45:20 -0400 Subject: [PATCH 064/916] 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 065/916] 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 066/916] 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 067/916] 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 068/916] 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 069/916] 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 070/916] 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 071/916] 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 072/916] 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 fa6c9ffccd4fbb83fff943db7cdde86a7cea5e82 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 073/916] Web: IndexedDB puts and deletes resolve when their transaction commits (#1242) --- apps/effectcraft-web/js/host.js | 24 +++- apps/effectcraft-web/tests/storage.mjs | 180 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft-web/tests/storage.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 3db6ad35c..f113c2a2d 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -57,6 +57,26 @@ function req(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } +// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has +// committed. A request's success doesn't mean the write is stored: the transaction can still +// abort afterwards (quota at commit, the browser closing), rolling it back, and a write +// reported as saved then is never retried (#1242). A failed request aborts the transaction +// with its error; an explicit abort leaves no error, hence the AbortError. +function write(db, store, op) { + return new Promise((res, rej) => { + let tx = null; + try { + tx = db.transaction(store, "readwrite"); + tx.oncomplete = () => res(); + tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError")); + op(tx.objectStore(store)); + } catch (e) { + try { tx?.abort(); } catch {} + rej(e); + } + }); +} + async function idb() { if (!self.indexedDB) throw new Error("no IndexedDB"); const open = indexedDB.open(DB, 1); @@ -70,10 +90,10 @@ async function idb() { return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); }, async put(key, data, modified) { - await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified })); }, async del(key) { - await req(tx("readwrite").delete(key)); + await write(db, STORE, (s) => s.delete(key)); }, }; } diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs new file mode 100644 index 000000000..0855c1674 --- /dev/null +++ b/apps/effectcraft-web/tests/storage.mjs @@ -0,0 +1,180 @@ +// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no +// browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/storage.mjs +// +// A write is acknowledged only once its transaction has committed, not when its request +// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be +// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite +// transaction commits once its requests are done, unless it was aborted (which rolls back its +// writes). `fault` makes the next request do something else. +let fault = null; // "abortAfterSuccess" | "requestError" | "throw" +const databases = new Map(); + +class Request { + constructor(tx) { + this.transaction = tx; + this.result = undefined; + this.error = null; + } +} + +class Store { + constructor(tx, rows) { + this.tx = tx; + this.rows = rows; + } + request(apply) { + const tx = this.tx; + if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError"); + const f = fault; + fault = null; + if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError"); + const r = new Request(tx); + tx.pending++; + setTimeout(() => { + tx.pending--; + if (tx.finished) return; + if (f === "requestError") { + r.error = new DOMException("the key already exists", "ConstraintError"); + const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + r.onerror?.(e); + tx.onerror?.(e); + if (!e.defaultPrevented) tx.fail(r.error); + return; + } + r.result = apply(tx.staged); + r.onsuccess?.({ target: r }); + if (f === "abortAfterSuccess") tx.abort(); + tx.maybeCommit(); + }, 0); + return r; + } + put(v) { + return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; }); + } + delete(k) { + return this.request((rows) => { rows.delete(k); }); + } + get(k) { + return this.request((rows) => rows.get(k)); + } + getAll() { + return this.request((rows) => [...rows.values()]); + } +} + +class Transaction { + constructor(db, name) { + this.db = db; + this.name = name; + this.staged = new Map(db.stores.get(name)); + this.pending = 0; + this.finished = false; + this.error = null; + setTimeout(() => this.maybeCommit(), 0); + } + objectStore(name) { + const s = new Store(this, this.staged); + s.keyPath = this.db.keyPaths.get(name); + return s; + } + maybeCommit() { + if (this.finished || this.pending > 0) return; + this.finished = true; + this.db.stores.set(this.name, this.staged); + setTimeout(() => this.oncomplete?.(), 0); + } + fail(error) { + this.error = error; + this.abort(); + } + abort() { + if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError"); + this.finished = true; + setTimeout(() => this.onabort?.(), 0); + } +} + +class Database { + constructor() { + this.stores = new Map(); + this.keyPaths = new Map(); + } + createObjectStore(name, { keyPath }) { + this.stores.set(name, new Map()); + this.keyPaths.set(name, keyPath); + } + transaction(name) { + if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError"); + return new Transaction(this, name); + } +} + +globalThis.indexedDB = { + open(name) { + const r = new Request(null); + setTimeout(() => { + let db = databases.get(name); + if (!db) { + db = new Database(); + databases.set(name, db); + r.result = db; + r.onupgradeneeded?.(); + } + r.result = db; + r.onsuccess?.({ target: r }); + }, 0); + return r; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +assert.equal(await host.storeOpen("indexeddb"), "indexeddb"); +const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key); +const bytes = (s) => new TextEncoder().encode(s); +const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data)); + +// A healthy write resolves once committed, and the store has it then. +await host.storePut("project.json", bytes("v1"), 1); +assert.equal(text(stored("project.json")), "v1", "a resolved put is committed"); + +// The put's request succeeds, then the transaction aborts: the put fails, the old value stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back"); + +// The same for a delete: it fails and the entry stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back"); + +// A failed request aborts its transaction with the request's error. +fault = "requestError"; +await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" }); +assert.equal(text(stored("project.json")), "v1"); + +// A request that throws at once (a value that can't be stored) fails the write. +fault = "throw"; +await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" }); +assert.equal(text(stored("project.json")), "v1"); + +// The retry after a failure commits the latest value, and a later delete removes it. +await host.storePut("project.json", bytes("v5"), 5); +assert.equal(text(stored("project.json")), "v5"); +await host.storeDelete("project.json"); +assert.equal(stored("project.json"), undefined, "a resolved delete is committed"); +const all = await host.storeLoadAll(); +assert.deepEqual(all, [], "nothing else was written"); + +await tick(); +console.log("storage: ok"); From f61f8c995846d5c641128cd5c46b7485e7e4e0c7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 074/916] Web: remembering and forgetting Media Browser folders waits for the commit too (#1242) --- apps/effectcraft-web/js/host.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index f113c2a2d..bab20f75a 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -570,7 +570,7 @@ async function openFolder(handle, remember) { if (remember) { try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + await write(db, "folders", (s) => s.put({ name, handle })); } catch (e) { console.info("media browser: cannot remember the folder", e); } } return { name, entries }; @@ -622,7 +622,7 @@ export async function browseForget(name) { folders.delete(name); try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + await write(db, "folders", (s) => s.delete(name)); } catch {} } From c03cd79b5910d69ec05155866275d75661cbf25a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 075/916] Docs: the IndexedDB storage test (#1242) --- docs/web.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/web.md b/docs/web.md index e73f7a899..7b5cd5c5b 100644 --- a/docs/web.md +++ b/docs/web.md @@ -432,6 +432,12 @@ a fake DOM: a picked file that can't be read is reported while the others are ad device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). +`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js` +with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one +whose transaction aborts after its request succeeded (or whose request fails or throws) rejects +and leaves the stored entry as it was, so the flush keeps it for a retry (#1242) +(`node apps/effectcraft-web/tests/storage.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From f2cdad9edbc787929987470a20382d252ba7344d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:57:19 -0400 Subject: [PATCH 076/916] 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 077/916] 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 078/916] 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 079/916] 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 080/916] 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 081/916] 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 082/916] 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 083/916] 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 084/916] 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 085/916] 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 086/916] 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 087/916] 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 088/916] 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 1b44c5c98aac86b3e2d9f5d7b33b7125c292cc63 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 09:15:08 +0200 Subject: [PATCH 089/916] Scripting: Stroke and Hue/Saturation parameters use After Effects' match-name numbers (part of #1405) --- crates/script/src/matchnames.rs | 44 ++++++++++++++++++++---- crates/script/src/tests.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 60a40e695..205697b60 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -399,7 +399,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { } /// Effects whose documented parameter match names are not numbered in display order: -/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. +/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of +/// these effects that After Effects doesn't have are numbered after the listed ones (#1405). const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "fillMask", 1), ("ec.generate.fill", "color", 2), @@ -408,8 +409,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "opacity", 5), ("ec.generate.fill", "invert", 6), ("ec.generate.fill", "allMasks", 7), + ("ec.generate.stroke", "path", 1), + ("ec.generate.stroke", "color", 2), + ("ec.generate.stroke", "brushSize", 3), + ("ec.generate.stroke", "brushHardness", 4), + ("ec.generate.stroke", "opacity", 5), + ("ec.generate.stroke", "spacing", 6), + ("ec.generate.stroke", "paintStyle", 7), + ("ec.generate.stroke", "start", 8), + ("ec.generate.stroke", "end", 9), + ("ec.generate.stroke", "allMasks", 10), + ("ec.generate.stroke", "strokeSequentially", 11), + // -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel + // controls are ours (After Effects edits the selected channel with the Master ones). + ("ec.color.huesaturation", "channelControl", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "saturation", 5), + ("ec.color.huesaturation", "lightness", 6), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.huesaturation", "colorizeHue", 8), + ("ec.color.huesaturation", "colorizeSaturation", 9), + ("ec.color.huesaturation", "colorizeLightness", 10), ]; +/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`. +fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize { + let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n); + if let Some(n) = listed(id) { + return n; + } + let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else { + return pos + 1; + }; + let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count(); + last + unlisted_before + 1 +} + /// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` /// params are numbered `-0001…` in parameter order, like After Effects. pub fn node(chain: &[&Node]) -> String { @@ -432,11 +467,8 @@ pub fn node(chain: &[&Node]) -> String { let fx = parent.and_then(Node::as_group); match fx { Some(g) => { - let i = PARAM_NUMBERS - .iter() - .find(|(fx, p, _)| *fx == g.match_id && *p == id) - .map(|(_, _, n)| *n) - .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1); + let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0); + let i = param_number(&g.match_id, &g.children, id, pos); format!("{}-{i:04}", effect(&g.match_id)) } None => own(), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6ad..f47c212f0 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -126,6 +126,65 @@ fn fill_parameters_use_documented_match_names() { assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007")); } +#[test] +fn stroke_parameters_use_after_effects_match_names() { + // #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = []; + for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name); + out.push(fx.property("Color").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Path", + "Color", + "Brush Size", + "Brush Hardness", + "Opacity", + "Spacing", + "Paint Style", + "Start", + "End", + "All Masks", + "Stroke Sequentially", + "ADBE Stroke-0002" + ]) + ); +} + +#[test] +fn hue_saturation_parameters_use_after_effects_match_names() { + // #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls, + // which After Effects doesn't expose, are numbered after its parameters so none takes an AE number. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = []; + var n = [2, 4, 5, 6, 7, 8, 9, 10]; + for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name); + out.push(fx.property("Red Hue").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Channel Control", + "Master Hue", + "Master Saturation", + "Master Lightness", + "Colorize", + "Colorize Hue", + "Colorize Saturation", + "Colorize Lightness", + "ADBE HUE SATURATION-0011" + ]) + ); +} + #[test] fn undo_steps_without_a_group() { let mut s = session(); From 69d17d89afead1ce66d10429c661fb74be5571bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:25:46 +0200 Subject: [PATCH 090/916] Keyframes: a path pasted onto Position becomes a motion path (fixes #1425) --- crates/engine/src/commands/anim.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/commands/edit.rs | 7 +++ crates/engine/src/tests_menu_cmds.rs | 54 +++++++++++++++++ docs/target-app-parity.md | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0df..325845115 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -1,8 +1,8 @@ //! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key //! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. -use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; -use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease}; +use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if clip.is_empty() { return Err(bad("keys.paste", "no keyframes have been copied")); } + // A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425). + if let [c] = clip.as_slice() + && let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value) + && let Some(target) = path_target(s, p)? + { + return paste_path(s, p, target, path); + } let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); @@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result { }) } +/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path: +/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none. +pub(super) fn path_target(s: &Session, p: &Value) -> Result> { + let (cid, targets) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_)); + let explicit = targets.first().and_then(|lid| { + let l = comp.layer(*lid)?; + let pr = match p.get("prop").and_then(Value::as_u64) { + Some(u) => l.props.find(u), + None => l.props.prop(str_p(p, "path")?), + }?; + Some((*lid, pr)) + }); + if p.get("prop").is_some() || p.get("path").is_some() { + return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid))); + } + let selected = s + .state + .selected_props + .iter() + .filter(|(lid, _)| targets.contains(lid)) + .find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion)); + Ok(selected.map(|(lid, uid)| (cid, *lid, *uid))) +} + +/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per +/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first +/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the +/// vertices are pasted as they are (layer-space numbers, not converted to the target's space). +pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut order: Vec = (0..path.vertices.len()).collect(); + if path.closed && !order.is_empty() { + order.push(0); + } + if order.is_empty() { + return Err(bad("keys.paste", "the copied path has no points")); + } + let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX); + let at = |xy: Option<&[f64; 2]>| { + let [x, y] = xy.copied().unwrap_or_default(); + [x, y, 0.0] + }; + let last = order.len() - 1; + let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order + .iter() + .enumerate() + .map(|(i, &v)| { + let [x, y, z] = at(path.vertices.get(v)); + let value = match target.props.find(uid).map(|pr| &pr.value) { + Some(KValue::Vec2(_)) => KValue::Vec2([x, y]), + _ => KValue::Vec3([x, y, z]), + }; + let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX)))); + (time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last) + }) + .collect(); + s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; + let mut sel = vec![]; + for (time, value, spatial_in, spatial_out, roving) in keys { + let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) }; + match pr.keys.binary_search_by(|x| x.time.cmp(&time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(time); + } + // Re-timing keeps the keys' order, so the pasted keys are found again by index. + let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); + retime_roving(&mut pr.keys, pr.spatial); + st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(json!(st.selected_keys.len())) + }) +} + /// Select every keyframe of the selected properties, or of the given / selected layers. fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1c..10bb10d15 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + // A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425). + if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice() + && let Some(target) = super::anim::path_target(s, p)? + { + let path = path.clone(); + return super::anim::paste_path(s, p, target, &path); + } if !s.state.value_clipboard.is_empty() { return paste_properties(s, p); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f6..6310e2520 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1396,6 +1396,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert_eq!(vals, vec![10.0, 0.0], "reversed"); } +/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion +/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI, +/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers +/// as they are, so the source layer's and the target parent's transforms don't move them. +#[test] +fn a_copied_path_pastes_onto_position_as_a_motion_path() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + // A moved and scaled source: converting to comp space would give 2v + (200, 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap(); + let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]); + s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap(); + // B's parent would shift a converted Position by (0, −50). + let parent = solid(&mut s, "#0000ff"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap(); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap(); + let motion = |s: &Session, id: u64| { + let l = layer(s, id); + let pr = l.props.prop("transform/position").unwrap().clone(); + pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::>() + }; + let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| { + let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]]; + assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}"); + for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() { + assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}"); + assert_eq!(*roving, i != 0 && i != 4, "key {i} roving"); + assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}"); + } + assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI"); + assert!(keys.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent"); + }; + // A Mask Path keyframe, pasted at 1 s. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, b), 1.0); + // The same path as an unanimated mask, pasted at 5 s onto an unparented layer. + s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let c = solid(&mut s, "#ffffff"); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, c), 5.0); +} + #[test] fn j_and_k_stop_at_keys_markers_and_the_work_area() { let mut s = comp(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd8..2301fae7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -746,6 +746,9 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks. - **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties). + A Mask Path or shape Path (its value or a key) pasted onto Position becomes a motion path: a + key per vertex with the path's tangents, over 2 s from the CTI, the inner keys roving, the + vertices pasted as they are (layer space, as in After Effects) (#1425). - **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected key's property, time and value. From 59b144439ef5259f235b854ff49da041d5be5c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:10 +0000 Subject: [PATCH 091/916] #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 092/916] #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 66a29505a580d91c7ee6b4eba123e3cc7c88a9fa Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Thu, 15 Oct 2026 00:19:47 +0900 Subject: [PATCH 093/916] i18n(ja): the Settings dialog in Japanese Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..7ac3943de 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -631,6 +631,189 @@ const JAPANESE: &[(&str, &str)] = &[ ("After Effects project", "After Effectsプロジェクト"), ("Lottie animation", "Lottieアニメーション"), ("Project", "プロジェクト"), + ("General", "一般"), + ("Language", "言語"), + ("Levels of Undo", "取り消しレベル"), + ("Path Point and Handle Size", "パスポイントとハンドルサイズ"), + ("Recent Projects Shown", "最近使用したプロジェクトの表示数"), + ("Options", "オプション"), + ("Show Tool Tips", "ツールヒントを表示"), + ("Create Layers at Composition Start Time", "レイヤーをコンポジションの開始時間に作成"), + ("Switches Affect Nested Comps", "ネストしたコンポジションにスイッチを適用"), + ("Default Spatial Interpolation to Linear", "初期設定の空間補間法にリニアを使用"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "マスク編集時に一定の頂点数とぼかしポイント数を保持"), + ("Synchronize Time of All Related Items", "すべての関連アイテムの時間を同期"), + ("Expression Pick Whip Writes Compact English", "ピックウィップによる簡潔な英語でのエクスプレッションの記述"), + ("Create Split Layers Above Original Layer", "元のレイヤーの上に分割したレイヤーを作成"), + ("Use System Color Picker", "システムのカラーピッカーを使用"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraftは常に独自のカラーピッカーを使用します(すべてのプラットフォームとWeb版で同じです)。", + ), + ("Startup & Repair", "起動と修復"), + ("Show Home Screen When Launching", "起動時にホーム画面を表示"), + ("Show Home Screen When Opening a Project", "プロジェクトを開くときにホーム画面を表示"), + ("Offer to Open the Latest Auto-Save After a Crash", "クラッシュ後に最新の自動保存を開くか確認する"), + ("Window Graphics", "ウィンドウグラフィックス"), + ("Automatic", "自動"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ウィンドウグラフィックスは次回の起動から適用されます。ウィンドウを開くときにグラフィックスドライバーがEffectCraftを停止させた場合、次回の起動ではOpenGLに切り替わります。", + ), + ("Repair", "修復"), + ("Reset Settings", "設定をリセット"), + ("Reset Keyboard Shortcuts", "キーボードショートカットをリセット"), + ("Empty Disk Cache", "ディスクキャッシュを空にする"), + ("New Project Loads Template", "新規プロジェクト読み込みテンプレート"), + ("Template Project", "テンプレートプロジェクト"), + ("Auto-Save", "自動保存"), + ("Automatically Save Projects", "プロジェクトを自動保存"), + ("Save Every", "保存の間隔"), + ("Minutes", "分"), + ("Maximum Project Versions", "プロジェクトバージョンの最大数"), + ("Auto-Save Location", "自動保存の場所"), + ("Next to Project", "プロジェクトの横"), + ("Custom Location", "ユーザー定義の場所"), + ("Save When Starting Render Queue", "レンダーキューの開始時に保存"), + ("Save Now", "今すぐ保存"), + ("Motion Path", "モーションパス"), + ("All Keyframes", "すべてのキーフレーム"), + ("No Motion Path", "モーションパスなし"), + ("No More Than (Seconds)", "次の秒数以下"), + ("No More Than (Keyframes)", "次のキーフレーム以下"), + ("Seconds", "秒"), + ("Keyframes", "キーフレーム"), + ("Display", "表示"), + ("Show Rendering Progress in Info Panel and Flowchart", "情報パネルとフローチャートにレンダリングの進行状況を表示"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "ハードウェアによるコンポジション、レイヤー、フッテージパネルの高速化"), + ("Panels are always drawn by the GPU (wgpu).", "パネルは常にGPU(wgpu)で描画されます。"), + ("Previews", "プレビュー"), + ("Adaptive Resolution Limit", "適応解像度の上限"), + ("Show Internal Wireframes", "内部ワイヤーフレームを表示"), + ("Cache Frames When Idle", "アイドル時にフレームをキャッシュ"), + ("Viewer Zoom Quality", "ズームの画質"), + ("Faster", "高速"), + ("More Accurate", "精度を優先"), + ("Display Color Space", "表示色空間"), + ("GPU Information...", "GPU 情報..."), + ("Appearance", "外観"), + ("Brightness", "明るさ"), + ("UI Scale", "UI スケール"), + ("Labels and Colors", "ラベルとカラー"), + ("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"), + ("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"), + ("Cycle Mask Colors", "新規マスクに別のカラーを使用"), + ("Use Gradients", "グラデーションを使用"), + ("Menu Bar", "メニューバー"), + ("Grids & Guides", "グリッド&ガイド"), + ("Gridline Every", "グリッドの間隔"), + ("Subdivisions", "分割数"), + ("Style", "スタイル"), + ("Lines", "線"), + ("Dashed Lines", "破線"), + ("Dots", "ドット"), + ("Horizontal", "水平方向"), + ("Vertical", "垂直方向"), + ("Safe Margins", "セーフマージン"), + ("Action-safe", "アクションセーフ"), + ("Title-safe", "タイトルセーフ"), + ("Labels", "ラベル"), + ("Text Engine", "テキストエンジン"), + ("Latin", "ラテン"), + ("South Asian and Middle Eastern", "南アジアおよび中東"), + ("Show Font Preview", "フォントのプレビューを表示"), + ("Number of Recent Fonts to Display", "最近使用したフォントの表示数"), + ("Show Font Names in English", "フォント名を英語で表示"), + ("Still Footage", "静止画フッテージ"), + ("Length of Composition", "コンポジションの長さ"), + ("Duration (Seconds)", "デュレーション(秒)"), + ("Still Duration", "静止画のデュレーション"), + ("Sequence Footage", "シーケンスフッテージ"), + ("Report Missing Frames", "不明なフレームをレポート"), + ("Automatically Reload Footage", "フッテージの自動再読み込み"), + ("Off", "オフ"), + ("Non-Sequence Footage", "非シーケンスフッテージ"), + ("All Footage", "すべてのフッテージ"), + ("Ask User", "ユーザーに確認"), + ("Guess", "推測"), + ("Ignore", "無視"), + ("Straight", "そのまま"), + ("Premultiplied", "プリマルチプライ"), + ("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"), + ("Footage", "フッテージ"), + ("Composition - Retain Layer Sizes", "コンポジション - レイヤーサイズを保持"), + ("Export", "書き出し"), + ("Default Output Folder", "既定の出力フォルダー"), + ("Segment Sequences", "シーケンスをセグメント化"), + ("Files per Segment", "セグメントあたりのファイル数"), + ("files", "ファイル"), + ("Segment Movie Files", "ムービーファイルをセグメント化"), + ("Segment Size", "セグメントサイズ"), + ("Append Bit Depth to File Name", "ファイル名にビット深度を追加"), + ("Audio", "オーディオ"), + ("Audio Hardware", "オーディオハードウェア"), + ("Preview Sample Rate", "サンプルレート"), + ("Audio Output Mapping", "オーディオ出力マッピング"), + ("Left", "左"), + ("Right", "右"), + ("channel", "チャンネル"), + ("Disk", "ディスク"), + ("Disk Cache", "ディスクキャッシュ"), + ("Enable Disk Cache", "ディスクキャッシュを有効にする"), + ("Maximum Disk Cache Size", "最大ディスクキャッシュサイズ"), + ("Disk Cache Folder", "ディスクキャッシュフォルダー"), + ("Media Cache", "メディアキャッシュ"), + ("Database and Cache Folder", "データベースとキャッシュのフォルダー"), + ("Conformed Audio Folder", "適合オーディオフォルダー"), + ("Memory & CPU", "メモリと CPU"), + ("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"), + ("Cache Budgets", "キャッシュの割り当て"), + ("Layer Cache", "レイヤーキャッシュ"), + ("Footage Frame Cache", "フッテージフレームキャッシュ"), + ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), + ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("Video", "ビデオ"), + ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), + ("Video Device", "ビデオデバイス"), + ("Floating Window", "フローティングウィンドウ"), + ("Full Screen", "全画面"), + ("Video Output During Playback", "再生中のビデオ出力"), + ("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"), + ("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"), + ("Default 3D Renderer", "既定の 3D レンダラー"), + ("Show 3D Reference Axes", "3D参照軸を表示"), + ("Extended Viewer", "拡張ビューアー"), + ("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"), + ("Scripting & Expressions", "スクリプトとエクスプレッション"), + ("Application Scripting", "アプリケーションのスクリプト"), + ("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"), + ("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"), + ("Enable JavaScript Debugger", "JavaScript デバッガーを使用"), + ("Expressions Editor", "エクスプレッションエディター"), + ("Font Size", "フォントサイズ"), + ("Syntax Highlighting", "構文のハイライト"), + ("Line Numbers", "行番号を表示"), + ("Auto-complete", "オートコンプリート"), + ("Bracket Matching", "対応する括弧をハイライト"), + ("Word Wrap", "折り返し"), + ("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。", + ), + ("Roto Brush", "ロトブラシ"), + ("Segmentation Model", "セグメンテーションモデル"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ロトブラシ2.0と3.0はここで選んだモデルを使用します(バージョン1.0は常に従来のエンジンを使用します)。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Face Tracking", "フェイストラッキング"), + ("Face Model", "フェイスモデル"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Match System", "システムに合わせる"), ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), From eb95f732e5ae2aabda0443ec941173dc2960d9bc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 094/916] Media: millisecond-rounded MP4 frame times read at the rate they average to (#1442) Phones and messaging apps store frame times rounded to whole milliseconds (33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the median duration, so such clips imported at 30.303 or 58.824 fps, and New Comp from Selection and renders followed that rate. When the median is no standard rate, the durations near it are averaged and snapped to the standard rate within 0.5 %; exact and variable rates keep the reader's estimate. --- crates/media/src/probe.rs | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..dad34fd13 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef) -> Result { // A movie is read in place (its index), not loaded whole. if let Some(src) = crate::stream::probe_source(path) { let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); - let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + let header = crate::stream::movie_header(path); + let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); + correct_rate(&mut f, header.as_deref().and_then(video_rate)); return Ok(f); } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -95,12 +97,24 @@ 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); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); turn_footage(&mut f, turn); + correct_rate(&mut f, rate); Ok(f) } +/// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's +/// own estimate misreads it. +fn correct_rate(f: &mut Footage, rate: Option) { + if f.kind == FootageKind::Video + && let Some(rate) = rate + { + f.frame_rate = rate; + } +} + /// The size as displayed: a movie shot with the camera on its side trades width and height. fn turn_footage(f: &mut Footage, turn: Orientation) { if swaps_sides(turn) { @@ -254,3 +268,41 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { (false, _, false) => Orientation::Rotate270, } } + +/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median +/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole +/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose +/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median +/// reads right. +fn video_rate(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration)) +} + +/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their +/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a +/// standard rate already, or when the average is no standard rate (variable or unusual rates keep +/// the reader's estimate). +fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option { + let ts = i128::from(timescale); + let mut durs: Vec = durations.filter(|&d| d > 0).map(u64::from).collect(); + durs.sort_unstable(); + let median = *durs.get(durs.len() / 2)?; + if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) { + return None; + } + // Durations within a quarter of the median (widened, so extreme durations can't overflow). + let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d))); + if sum == 0 { + return None; + } + let fps = timescale as f64 * n as f64 / sum as f64; + FrameRate::COMMON + .into_iter() + .map(|r| (r, (fps / r.as_f64() - 1.0).abs())) + .filter(|&(_, off)| off <= 0.005) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(r, _)| r) +} + From 36a905293b65d4487528456cf6d308e89fd7283d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 095/916] Media: tests for the averaged MP4 frame rate (#1442) --- crates/media/src/probe.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index dad34fd13..2793be251 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -306,3 +306,48 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option .map(|(r, _)| r) } +#[cfg(test)] +mod tests { + use super::*; + + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. + fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { + let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() + } + + #[test] + fn millisecond_rounded_frame_times_average_to_their_rate() { + assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30)); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60)); + // NTSC rates stay NTSC, not the round rate next to them. + for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}"); + } + // A short clip: its few frames average to the rate as well. + assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30)); + } + + #[test] + fn exact_and_variable_rates_keep_the_readers_estimate() { + // A median that is a standard rate already reads right. + assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None); + assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None); + assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None); + // Durations averaging to no standard rate (22.2 fps). + assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None); + // A truly 30.3 fps clip is not pulled to 30 (1 % off). + assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None); + } + + #[test] + fn degenerate_durations_have_no_rate() { + assert_eq!(averaged_rate(1000, std::iter::empty()), None); + assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None); + assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None); + assert_eq!(video_rate(b"not a movie"), None); + } +} From 8b536e597bab07116fb2776f23473549e868e329 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:37:36 -0400 Subject: [PATCH 096/916] Export: millisecond-rounded footage renders at its rate, each frame once (#1442) --- crates/export/tests/ms_rounded_footage.rs | 148 ++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/export/tests/ms_rounded_footage.rs diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/ms_rounded_footage.rs new file mode 100644 index 000000000..3d6880232 --- /dev/null +++ b/crates/export/tests/ms_rounded_footage.rs @@ -0,0 +1,148 @@ +//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging +//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It +//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's +//! rate) renders every source frame once, and the H.264 output has that rate and frame count. +//! +//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing +//! their index in binary (eight bars, white for a set bit). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const W: u32 = 128; +const H: u32 = 32; +const BARS: u32 = 8; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds. +fn ms_rounded(rate: FrameRate, n: i64) -> Vec { + let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect() +} + +/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms); +/// frame k shows k in binary. +fn write_movie(path: &Path, durations: &[u32]) { + let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1); + cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + cfg.aud = false; + cfg.bframes = 0; + cfg.keyint = 30; + cfg.scenecut = false; + let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder"); + let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]); + let mut packets = Vec::new(); + for k in 0..durations.len() { + let y: Vec = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect(); + let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; + packets.extend(enc.try_encode(&frame, k as i64).expect("encode")); + } + packets.extend(enc.flush()); + assert_eq!(packets.len(), durations.len(), "one access unit per frame"); + let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC"); + let file = std::fs::File::create(path).expect("create"); + let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux"); + let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track"); + for (p, &duration) in packets.iter().zip(durations) { + mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample"); + } + mux.finish().expect("finish"); +} + +/// The frame index a decoded frame shows. +fn shown(img: &Image) -> u32 { + (0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum() +} + +fn frame_count(f: &Footage) -> i64 { + f.frame_rate.frame_at(f.duration - Tick(1)) + 1 +} + +/// Every frame of a movie in order, as indices. +fn frames_of(f: &Footage) -> Vec { + let pool = MediaPool::new(); + (0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect() +} + +/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate +/// and duration), render it to H.264 and return the output's footage. +fn comp_from_footage(path: &Path, out: &Path) -> Footage { + let footage = effectcraft_media::probe(path).expect("probe"); + let mut p = Project::default(); + let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone())); + let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate"); + let mut comp = Comp::new(W, H, rate, footage.duration); + comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None)); + let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let pool = MediaPool::new(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; + let out_path = out.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &pool, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: &out_path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames"); + effectcraft_media::probe(out).expect("probe the render") +} + +#[test] +fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() { + let d = out_dir("ms_rounded"); + for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, frames)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage"); + let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into(); + let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes"); + assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory"); + let all: Vec = (0..frames as u32).collect(); + assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once"); + + let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num))); + assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render"); + assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration"); + assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated"); + } +} + +#[test] +fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() { + let d = out_dir("ms_rounded_controls"); + for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, 60)); + assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate); + } + // Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands. + let path = d.join("variable.mp4"); + write_movie(&path, &[40, 45, 50].repeat(20)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate); +} From 2f437ea551de3b8f512d28f10a46fe58a509ed78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 09:38:47 -0700 Subject: [PATCH 097/916] EPS and PDF: huge arcs, long paths and loop passes count against the import's limits The EPS interpreter counted one operation against its budget for each name it ran, whatever the operator did, and paths had no length limit. Parsing one arc at a radius of 10^300 passed 3 GiB of resident memory within 43 seconds. A loop of arc at a radius of 2.2 x 10^18 was still running after 60 seconds, and a loop of pathbbox on a path of 100,001 elements and { {} loop } loop did not finish within 20 seconds. PDF content had no path limit either: parsing a page that repeats 0 0 1 1 re 4,000,000 times peaked at 2.3 GiB of resident memory. A path now holds at most 1,048,576 elements (MAX_PATH, the limit of VectorCraft's EPS interpreter). In EPS, moveto, lineto, curveto, closepath, arc, charpath and the rect operators check it, and an operator that would take a path past it stops the program with "PostScript (path limit)". In PDF, m, l, c, v, y, h and re (each adds at most five elements) stop the content stream with "content (path limit)" when the path already holds more than 1,048,571. Each path element an operator makes, reads or copies (arc, rectangle arrays, charpath, pathbbox, clip, eoclip, gsave, save) now counts against the EPS budget. Each pass of repeat, for, loop and forall counts too, and so does each procedure item that is not a name. A moveto right after a moveto replaces it, as the PostScript Language Reference describes, so text placed with moveto and show leaves one element. Five tests in tests_import.rs cover the loops, the path limit in both interpreters and runs of moveto and show. --- crates/pdf/src/build.rs | 4 + crates/pdf/src/page.rs | 9 +- crates/pdf/src/ps.rs | 174 +++++++++++++++++++++++---------- crates/pdf/src/tests_import.rs | 94 ++++++++++++++++++ 4 files changed, 228 insertions(+), 53 deletions(-) diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 7023e931c..0b1586f89 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -3,6 +3,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; +/// The most elements a path being built holds: an operator that would add more stops the +/// PostScript program, or the PDF content stream. +pub(crate) const MAX_PATH: usize = 1 << 20; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 13e98b3c9..1cf620f14 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -12,7 +12,7 @@ use effectcraft_svg::{ }; use kurbo::{Point, Shape as _}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::color::{Cs, Func, color_space, text_string}; use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; @@ -281,7 +281,12 @@ impl<'a> Interp<'a> { self.ext_gstate(&g, res, depth); } } - // Path construction. + // Path construction. Each operator adds at most five elements (`re`), so when fewer + // than five more fit within `MAX_PATH`, the operator stops the stream. + "m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => { + self.b.skip("content (path limit)"); + return; + } "m" => { let p = Point::new(n(0), n(1)); self.path.move_to(p); diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 65e352cb2..93ed55e74 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; @@ -441,7 +441,14 @@ impl Interp { break; } } - other => self.push(other.clone()), + // Other items count against the budget as names do. + other => { + r = self.spend(1); + if r.is_err() { + break; + } + self.push(other.clone()); + } } } self.calls -= 1; @@ -456,11 +463,7 @@ impl Interp { } fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { - if self.budget == 0 { - self.b.skip("PostScript (operation limit)"); - return Err(Flow::Quit); - } - self.budget -= 1; + self.spend(1)?; if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), @@ -473,6 +476,21 @@ impl Interp { self.builtin(n, src) } + /// Count `n` operations against the budget, and stop the program when fewer are left. An + /// operator that makes, reads or copies path elements counts each one as an operation. + fn spend(&mut self, n: usize) -> Result<(), Flow> { + match self.budget.checked_sub(n) { + Some(left) => { + self.budget = left; + Ok(()) + } + None => { + self.b.skip("PostScript (operation limit)"); + Err(Flow::Quit) + } + } + } + fn device_scale(&self) -> f64 { self.gs.ctm.determinant().abs().sqrt() } @@ -483,13 +501,34 @@ impl Interp { Point::new(x, y) } - fn move_to(&mut self, p: Point) { + /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. + fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + if len <= MAX_PATH { + return Ok(()); + } + self.b.skip("PostScript (path limit)"); + Err(Flow::Quit) + } + + /// [`Self::path_fits`] for the current path with `n` more elements. + fn room(&mut self, n: usize) -> Result<(), Flow> { + self.path_fits(self.gs.path.elements().len().saturating_add(n)) + } + + fn move_to(&mut self, p: Point) -> Result<(), Flow> { + // A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`). + if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) { + self.gs.path.pop(); + } + self.room(1)?; let q = self.gs.ctm * p; self.gs.path.move_to(q); self.gs.cur = Some(q); self.gs.start = Some(q); + Ok(()) } - fn line_to(&mut self, p: Point) { + fn line_to(&mut self, p: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let q = self.gs.ctm * p; if self.gs.cur.is_none() { self.gs.path.move_to(q); @@ -497,20 +536,23 @@ impl Interp { } self.gs.path.line_to(q); self.gs.cur = Some(q); + Ok(()) } - fn curve_to(&mut self, a: Point, b: Point, c: Point) { + fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let m = self.gs.ctm; if self.gs.cur.is_none() { self.gs.path.move_to(m * a); } self.gs.path.curve_to(m * a, m * b, m * c); self.gs.cur = Some(m * c); + Ok(()) } fn current_user(&self) -> Point { self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() } - fn arc(&mut self, neg: bool) { + fn arc(&mut self, neg: bool) -> Result<(), Flow> { let a2 = self.popn(); let a1 = self.popn(); let r = self.popn(); @@ -540,16 +582,23 @@ impl Interp { let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { - self.line_to(start); + self.line_to(start)?; } else { - self.move_to(start); + self.move_to(start)?; } let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); let m = self.gs.ctm; - arc.to_cubic_beziers(0.1, |p1, p2, p3| { + // kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a + // quarter turn at a radius of 10^300, more than a usize can count), so each one counts + // against the budget and the path limit. + let mut curves = arc.append_iter(0.1); + while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() { + self.room(1)?; + self.spend(1)?; self.gs.path.curve_to(m * p1, m * p2, m * p3); self.gs.cur = Some(m * p3); - }); + } + Ok(()) } fn paint(&mut self, fill: Option, stroke: bool) { @@ -569,11 +618,16 @@ impl Interp { self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); } - fn rect_path(&mut self) -> Vec { + fn rect_path(&mut self) -> Result, Flow> { // x y w h, or an array / string of numbers. let top = self.pop(); let nums: Vec = match &top { - V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Array(_) | V::Proc(_) => { + let items = top.items().unwrap_or_default(); + // An array's rectangles count against the budget, five path elements each. + self.spend(items.len() / 4 * 5)?; + items.iter().filter_map(V::num).collect() + } V::Num(h) => { let w = self.popn(); let y = self.popn(); @@ -582,8 +636,10 @@ impl Interp { } _ => vec![], }; + self.path_fits(nums.len() / 4 * 5)?; let m = self.gs.ctm; - nums.as_chunks::<4>() + Ok(nums + .as_chunks::<4>() .0 .iter() .map(|r| { @@ -595,7 +651,7 @@ impl Interp { p.close_path(); p }) - .collect() + .collect()) } fn grestore(&mut self) { @@ -809,10 +865,12 @@ impl Interp { let c = matches!(self.pop(), V::Bool(true)); self.exec_obj(if c { t } else { f }, src)?; } + // Each pass of a loop counts against the budget. "repeat" => { let p = self.pop(); let k = self.popn().clamp(0.0, 1e6) as usize; for _ in 0..k { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -826,6 +884,7 @@ impl Interp { let mut i = self.popn(); let mut guard = 0; while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; self.push(V::Num(i)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -838,6 +897,7 @@ impl Interp { "loop" => { let p = self.pop(); for _ in 0..1_000_000 { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -850,6 +910,7 @@ impl Interp { V::Dict(d) => { let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in items { + self.spend(1)?; self.push(V::Lit(k.into())); self.push(v); match self.exec_obj(p.clone(), src) { @@ -861,6 +922,7 @@ impl Interp { V::Str(s) => { let bytes = s.borrow().clone(); for c in bytes { + self.spend(1)?; self.push(V::Num(c as f64)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -870,6 +932,7 @@ impl Interp { } a => { for v in a.items().unwrap_or_default() { + self.spend(1)?; self.push(v); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -1127,6 +1190,7 @@ impl Interp { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { + self.spend(self.gs.path.elements().len())?; self.saved.push(self.gs.clone()); self.gs.depth = self.b.depth(); self.push(V::Null); @@ -1156,6 +1220,7 @@ impl Interp { } // Graphics state. "gsave" => { + self.spend(self.gs.path.elements().len())?; let mut g = self.gs.clone(); g.depth = self.b.depth(); self.saved.push(g); @@ -1316,21 +1381,21 @@ impl Interp { } "moveto" => { let p = self.user_point(); - self.move_to(p); + self.move_to(p)?; } "rmoveto" => { let d = self.user_point(); let c = self.current_user(); - self.move_to(Point::new(c.x + d.x, c.y + d.y)); + self.move_to(Point::new(c.x + d.x, c.y + d.y))?; } "lineto" => { let p = self.user_point(); - self.line_to(p); + self.line_to(p)?; } "rlineto" => { let d = self.user_point(); let c = self.current_user(); - self.line_to(Point::new(c.x + d.x, c.y + d.y)); + self.line_to(Point::new(c.x + d.x, c.y + d.y))?; } "curveto" | "rcurveto" => { let p3 = self.user_point(); @@ -1339,25 +1404,27 @@ impl Interp { if n == "rcurveto" { let c = self.current_user(); let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); - self.curve_to(o(p1), o(p2), o(p3)); + self.curve_to(o(p1), o(p2), o(p3))?; } else { - self.curve_to(p1, p2, p3); + self.curve_to(p1, p2, p3)?; } } "closepath" => { if self.gs.cur.is_some() { + self.room(1)?; self.gs.path.close_path(); self.gs.cur = self.gs.start; } } - "arc" => self.arc(false), - "arcn" => self.arc(true), + "arc" => self.arc(false)?, + "arcn" => self.arc(true)?, "currentpoint" => { let c = self.current_user(); self.push(V::Num(c.x)); self.push(V::Num(c.y)); } "pathbbox" => { + self.spend(self.gs.path.elements().len())?; let r = kurbo::Shape::bounding_box(&self.gs.path); let inv = self.gs.ctm.inverse(); let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); @@ -1376,13 +1443,14 @@ impl Interp { "stroke" => self.paint(None, true), "clip" | "eoclip" => { let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + self.spend(self.gs.path.elements().len())?; let p = self.gs.path.clone(); if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { self.b.clip(p, rule); } } "rectfill" | "rectstroke" | "rectclip" => { - let paths = self.rect_path(); + let paths = self.rect_path()?; if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { self.pop(); } @@ -1617,9 +1685,9 @@ impl Interp { /// A string's glyph outlines (device space) from the current point, with an extra /// displacement `extra(index, code)` (user space) after each glyph; moves the current /// point. - fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { - let d = self.gs.font.clone()?; - let (font, m) = self.font_of(&d)?; + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result, Flow> { + let Some(d) = self.gs.font.clone() else { return Ok(None) }; + let Some((font, m)) = self.font_of(&d) else { return Ok(None) }; let mut p = self.current_user(); let mut path = BezPath::new(); let mut text = String::new(); @@ -1634,8 +1702,8 @@ impl Interp { let (ex, ey) = extra(i, code); p += adv + kurbo::Vec2::new(ex, ey); } - self.move_to(p); - Some((path, text)) + self.move_to(p)?; + Ok(Some((path, text))) } fn fill_text(&mut self, path: BezPath, text: &str) { @@ -1651,11 +1719,12 @@ impl Interp { } } - fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { - match self.text_path(s, extra) { + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> { + match self.text_path(s, extra)? { Some((path, text)) => self.fill_text(path, &text), None => self.b.skip("text (no font)"), } + Ok(()) } /// Text and font operators (PostScript Language Reference §5.1, §8.2). @@ -1713,20 +1782,20 @@ impl Interp { "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), "show" => { let s = self.pop_string(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "ashow" => { let s = self.pop_string(); let ay = self.popn(); let ax = self.popn(); - self.show_with(&s, |_, _| (ax, ay)); + self.show_with(&s, |_, _| (ax, ay))?; } "widthshow" => { let s = self.pop_string(); let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?; } "awidthshow" => { let s = self.pop_string(); @@ -1735,18 +1804,18 @@ impl Interp { let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?; } "kshow" | "cshow" => { // The procedure between characters is not run. let s = self.pop_string(); self.pop(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "xshow" | "yshow" | "xyshow" => { let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); let s = self.pop_string(); - self.explicit_show(&s, &disp, n); + self.explicit_show(&s, &disp, n)?; } "glyphshow" => { let name = self.pop().key(); @@ -1757,7 +1826,7 @@ impl Interp { let tmp = transformed(&d, Affine::IDENTITY); tmp.borrow_mut().insert("Encoding".into(), array(e)); self.gs.font = Some(tmp); - self.show_with(b"A", |_, _| (0.0, 0.0)); + self.show_with(b"A", |_, _| (0.0, 0.0))?; self.gs.font = Some(d); } "charpath" => { @@ -1765,14 +1834,17 @@ impl Interp { let s = self.pop_string(); let saved = std::mem::take(&mut self.gs.path); let start = self.gs.start; - match self.text_path(&s, |_, _| (0.0, 0.0)) { + match self.text_path(&s, |_, _| (0.0, 0.0))? { Some((glyphs, _)) => { - let mut p = saved; - p.extend(glyphs); + // The outlines join the path: each element counts against the budget and + // the path limit. + self.gs.path = saved; + self.room(glyphs.elements().len() + 1)?; + self.spend(glyphs.elements().len())?; + self.gs.path.extend(glyphs); if let Some(c) = self.gs.cur { - p.move_to(c); + self.gs.path.move_to(c); } - self.gs.path = p; self.gs.start = start; } None => self.gs.path = saved, @@ -1822,7 +1894,7 @@ impl Interp { /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the /// advances. - fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> { let step = if n == "xyshow" { 2 } else { 1 }; let d: Vec<(f64, f64)> = (0..s.len()) .map(|k| { @@ -1837,13 +1909,13 @@ impl Interp { .collect(); let Some(fd) = self.gs.font.clone() else { self.b.skip("text (no font)"); - return; + return Ok(()); }; - let Some((font, m)) = self.font_of(&fd) else { return }; + let Some((font, m)) = self.font_of(&fd) else { return Ok(()) }; self.show_with(s, move |k, c| { let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); (dx - adv.x, dy - adv.y) - }); + }) } } diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 532e32cb9..de49ec408 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -359,6 +359,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() { assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); } +/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at +/// the operation limit. +fn runs_out_of_budget(body: &str) { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}"); +} + +#[test] +fn paths_made_in_a_loop_run_out_of_the_budget() { + // Each curve, rectangle element and glyph outline element an operator makes counts against the + // operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call, + // `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the + // outlines of 1,000 H's. + for body in [ + "{ newpath 0 0 2200000000000000000 0 1440 arc } loop", + "/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop", + "/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \ + { newpath 0 0 moveto s false charpath } loop", + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { + // `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it: + // each element counts against the operation budget. The path has 100,001 elements. + let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for"; + for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] { + runs_out_of_budget(&format!("{path} {body}")); + } +} + +#[test] +fn nested_loops_run_out_of_the_budget() { + // Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and + // so does each item of a procedure that is not a name. `clear` empties the operand stack that + // the passes fill. + let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000)); + for body in [ + "{ {} loop } loop", + "{ 1000000 {} repeat } loop", + "{ 0 1 999999 {} for clear } loop", + "/s 1000000 string def { s {} forall clear } loop", + "/a 1000000 array def { a {} forall clear } loop", + "/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop", + literals.as_str(), + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_stop_at_the_path_limit() { + // A path holds at most 1,048,576 elements. An operator that would add more stops the program: + // the square drawn before it stays, and the square after it is left out. + for body in [ + "newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat", + "newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat", + "newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat", + // 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves. + "newpath 0 0 200000000000000000 0 368640 arc", + // At a radius of 10^300, kurbo divides the arc into more curves than a usize can count. + "newpath 0 0 10 300 exp 0 90 arc", + // The outlines of 100,000 H's, 13 elements each. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s false charpath", + // 210,000 rectangles of five elements each. + "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", + ] { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}"); + } + // A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`), + // and so does the current point that `show` sets, so 550,000 pieces of text placed with + // `moveto` and `show` leave a path of one element. + let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn pdf_paths_stop_at_the_path_limit() { + // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) + // and `h` stop the stream at the limit, and the square painted before it stays. + for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] { + let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap(); + assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["content (path limit)"], "{op}"); + } +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). From a1a1a36a417d7a5ee6298780be0692f241f79eef Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:38:38 +0400 Subject: [PATCH 098/916] i18n: context menus, tab titles, workspaces and column headers reach the catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text drawn outside the menu bar stayed English in every language: - i18n::command_label: the Timeline / Composition layer and property context menus now take the menu bar's row for the same command (… or ...), else the panel catalog's row; the layer menu's Effects submenu draws effect and category names through the effect catalog. - The dock's tab titles (and the dragged tab), the Composition, Effect Controls and Properties tabs, and the workspace bar and menus use the window.panel and window.workspace rows every menu catalog already has. - The Project panel's search hint, details hint and column headers, and the Timeline's column headers and Columns menu go through tr(). The Simplified and Traditional Chinese rows for "Type" read 文字 (text, the Type tool); the string is the Camera Settings field and now the Project column, so they read 类型 / 類型. dock_ui.rs joins the converted files. Languages without a row for a string keep the English, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit b335f1e2764929b78d7b678ec026f680b1a8129f) --- crates/ui-egui/src/dock_ui.rs | 10 +++-- crates/ui-egui/src/header.rs | 6 +-- crates/ui-egui/src/i18n.rs | 58 +++++++++++++++++++-------- crates/ui-egui/src/i18n/ui.rs | 5 ++- crates/ui-egui/src/panels/project.rs | 14 +++++-- crates/ui-egui/src/panels/timeline.rs | 12 +++--- crates/ui-egui/src/panels/viewers.rs | 2 +- 7 files changed, 71 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..ba9e9e6b9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -41,8 +41,8 @@ impl EffectcraftApp { } let Some(comp) = self.session.active_comp() else { return out }; if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { - out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); - out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name))); + out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name))); } out } @@ -206,7 +206,9 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| { + titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string()) + }; let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +572,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..acbd2fdcb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -259,7 +259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if mresp.clicked() { widgets::open_popup(ui, egui::Id::new("ws-pop")); } - let names: Vec = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect(); + let names: Vec = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect(); if let Some(i) = widgets::popup_menu( ui, egui::Id::new("ws-pop"), @@ -274,7 +274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Small Screen, Standard. let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { - let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text); + let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0)); if r.min.x < x + 120.0 { @@ -641,7 +641,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { for w in app.workspace_names() { - if ui.selectable_label(app.ui.workspace == w, &w).clicked() { + if ui.selectable_label(app.ui.workspace == w, crate::i18n::command_label("window.workspace", &w)).clicked() { app.set_workspace(&w); ui.close(); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dde..8bc348df7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -118,25 +118,37 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } - match language { - "ja" => lookup(JAPANESE, command, source), - "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), - "zh-hant" => lookup(TRADITIONAL_CHINESE, command, source), - "uk" => lookup(UKRAINIAN, command, source), - "es" => lookup(SPANISH, command, source), - "pt-br" => lookup(PORTUGUESE, command, source), - "ru" => lookup(RUSSIAN, command, source), - "fr" => lookup(FRENCH, command, source), - "de" => lookup(GERMAN, command, source), - "cs" => lookup(CZECH, command, source), - _ => source, - } + lookup(language, command, source).unwrap_or(source) } -/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is +/// A command's label drawn outside the menu bar (the Timeline's and Composition panel's layer and +/// property context menus, the dock's tab titles, the workspace bar), in the language the panels +/// draw in: the menu bar's row for (`command`, `source`), else the panel catalog's row for +/// `source`, else `source`. Those menus write `…` where the menu bar's sources have `...`, so +/// either spelling finds the menu bar's row. +pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str { + let language = ui::current(); + let dotted = source.strip_suffix('…').map(|stem| format!("{stem}...")); + lookup(language, command, source).or_else(|| lookup(language, command, dotted.as_deref()?)).unwrap_or_else(|| tr(source)) +} + +/// The row for (command, English label) in `language`'s menu catalog; `None` when the pair is /// absent, so computed labels and user names stay verbatim. -fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str { - catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated).unwrap_or(source) +fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { + let catalog = match language { + "ja" => JAPANESE, + "zh-hans" => SIMPLIFIED_CHINESE, + "zh-hant" => TRADITIONAL_CHINESE, + "uk" => UKRAINIAN, + "es" => SPANISH, + "pt-br" => PORTUGUESE, + "ru" => RUSSIAN, + "fr" => FRENCH, + "de" => GERMAN, + "cs" => CZECH, + _ => return None, + }; + catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) } pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String { @@ -6617,6 +6629,20 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + /// Labels drawn outside the menu bar take the menu bar's row for their command (with `…` for + /// `...`), else the panel catalog's row, else stay as they are. + #[test] + fn command_label_reads_the_menu_row_then_the_panel_row() { + set_current("zh-hans"); + assert_eq!(command_label("window.panel", "Project"), "项目"); + let (command, en, translated) = SIMPLIFIED_CHINESE.iter().find(|(c, en, _)| !c.is_empty() && en.ends_with("...")).expect("a menu row ending in ..."); + assert_eq!(command_label(command, &format!("{}…", en.trim_end_matches("..."))), *translated); + assert_eq!(command_label("no.such.command", "Cancel"), "取消"); + assert_eq!(command_label("window.panel", "Comp 1"), "Comp 1"); + set_current("en"); + assert_eq!(command_label("window.panel", "Project"), "Project"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..3a271aeb1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1055,7 +1055,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -1787,7 +1787,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -4209,6 +4209,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..f2d9c303a 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -425,13 +425,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } else { let hint = "Select an item to see its details"; - let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint); + let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint); app.auto.add("project.details.hint", r, hint); } // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); let mut q = app.ui.project_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search project", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t); app.ui.project_search = q; app.auto.add("project.search", sr, "Search"); // Column header: Name, Label, then the visible optional columns. @@ -463,7 +463,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -1094,7 +1100,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..065517d2a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1786,7 +1786,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Column headers scroll with the outline and never spill into the time ruler. let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y))); let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim); if vis.av { hic(Icon::Eye, cw.av + 10.0); hic(Icon::Speaker, cw.av + 28.0); @@ -1870,7 +1870,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (id, label) in COLUMNS { let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label }; let on = column_visible(&app.ui.timeline, id); - if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() { + if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() { actions.push(("timeline.column".into(), json!({"column": id, "visible": !on}))); ui.close(); } @@ -3796,7 +3796,7 @@ fn prop_menu( ) { let (l, uid) = (layer.id.0, prop.uid); let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { - let resp = ui.add(egui::Button::new(label).selected(selected)); + let resp = ui.add(egui::Button::new(crate::i18n::command_label(cmd, label)).selected(selected)); auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); if resp.clicked() { actions.push((cmd.into(), params)); @@ -3877,7 +3877,7 @@ fn layer_context_menu( pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { - if ui.button(label).clicked() { + if ui.button(crate::i18n::command_label(cmd, label)).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); actions.push((cmd.into(), params)); ui.close(); @@ -3959,10 +3959,10 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut widgets::menu_scroll(ui, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); - ui.menu_button(cat, |ui| { + ui.menu_button(crate::i18n::effects::category(cat), |ui| { widgets::menu_scroll(ui, |ui| { for e in fx { - item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); + item(ui, &crate::i18n::effects::name(e.id, e.name), "effect.apply", json!({"effect": e.id, "layers": layers})); } }); }); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 8d2351330..140dd6f8a 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) { /// The tab label of viewer `id` ("Composition Intro"). pub fn title(app: &EffectcraftApp, id: u32) -> Option { let c = comp_of(app, id)?; - Some(format!("Composition {}", app.session.project.item(c)?.name)) + Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name)) } From 5539bb4a95c0e879c46a4f4263a81f59d30d399a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:12 +0400 Subject: [PATCH 099/916] web: Match System follows ?lang= and the browser's languages Match System (the default) stayed English in the browser build, which has no system-locale probe (#229). The page now passes ?lang= from the address, then navigator.languages, and Match System takes the first of them with a catalog (an English tag counts), else English. A language chosen in Settings still wins; native builds are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit 73c064e3858068ed19fbfafbdd0b3c8cc229a42e) --- apps/effectcraft-web/Cargo.toml | 1 + apps/effectcraft-web/src/app.rs | 5 +++++ crates/ui-egui/src/i18n.rs | 35 ++++++++++++++++++++++++++++++--- docs/preferences.md | 2 +- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 6c30f9a56..28ea2f369 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [ "HtmlElement", "HtmlInputElement", "Location", + "Navigator", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..a5fad30b1 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -184,6 +184,11 @@ fn install_hooks(app: &mut EffectcraftApp) { #[wasm_bindgen] pub async fn start(canvas_id: String) -> Result<(), JsValue> { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Match System follows `?lang=` in the address, then the browser's languages; a language + // chosen in Settings still wins. + let navigator = web_sys::window().map(|w| w.navigator()); + let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::>()); + effectcraft_ui_egui::i18n::set_system_locales(query_value("lang").into_iter().chain(browser).collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; let empty = query_flag("empty"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8bc348df7..315e2ba34 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,35 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. The browser build follows the locales its page passes in ([`set_system_locales`]). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + first_supported(SYSTEM_LOCALES.get().map(Vec::as_slice).unwrap_or_default()) +} + +/// The browser's preferred languages, most preferred first (`?lang=` in the address, then +/// `navigator.languages`), for Match System in the web build. +static SYSTEM_LOCALES: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Tell Match System the system's preferred languages (the web build has no locale probe of its +/// own). Set before the first frame; only the first call counts. +pub fn set_system_locales(tags: Vec) { + let _ = SYSTEM_LOCALES.set(tags); +} + +/// The first of `tags` EffectCraft has a catalog for (English counts), else English: someone who +/// prefers Dutch, then Czech, gets Czech. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported(tags: &[String]) -> &'static str { + tags.iter() + .map(|t| (t, supported(Some(t)))) + .find(|(t, code)| *code != "en" || t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en"))) + .map_or("en", |(_, code)| code) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -6643,6 +6662,16 @@ mod tests { assert_eq!(command_label("window.panel", "Project"), "Project"); } + #[test] + fn match_system_takes_the_first_browser_language_with_a_catalog() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(super::first_supported(&tags(&["cs-CZ", "cs", "en"])), "cs"); + assert_eq!(super::first_supported(&tags(&["nl-NL", "cs"])), "cs"); + assert_eq!(super::first_supported(&tags(&["en-US", "cs"])), "en"); + assert_eq!(super::first_supported(&tags(&["nl-NL"])), "en"); + assert_eq!(super::first_supported(&tags(&[])), "en"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a1..59b0fc8a9 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From fb51427ec0080226d8282e9c799277a750990060 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:40:33 +0400 Subject: [PATCH 100/916] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Georgian (ქართული, code ka) to Settings > General > Language, with Match System for ka / ka-GE / ka_GE.UTF-8 locales: - i18n.rs: the menu catalog, the same 628 (command, English label) keys as the other languages, plus the Undo/Redo entries, the workspace and 3D view shortcut submenus, the Reveal labels on Windows and Linux and the macOS Services items. - i18n/ui.rs: GEORGIAN, every row of the Chinese panel catalogs (687), and GEORGIAN_PANELS, 195 rows for text the panels send through tr() that no catalog has yet (form dialogs, drop messages, screen-reader names, context menus, Project and Timeline column headers). The tests hold them to the same rules (unique, same placeholders, used by a converted panel) and keep them from shadowing a Chinese row; prefs_live.rs joins the converted files. - i18n/effects.rs: Georgian names for all 307 effects and 21 categories; the CC series and brand names stay in English. - Properties > Text Animation: the Add Animator and Presets buttons grow with their translated labels (English keeps 116 / 84 px). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 3 +- crates/engine/src/prefs.rs | 1 + crates/engine/src/tests_settings.rs | 6 + crates/ui-egui/src/i18n.rs | 739 +++++++++++++++++- crates/ui-egui/src/i18n/effects.rs | 366 ++++++++- crates/ui-egui/src/i18n/ui.rs | 986 +++++++++++++++++++++++- crates/ui-egui/src/native_menu.rs | 23 + crates/ui-egui/src/panels/properties.rs | 6 +- docs/gaps.md | 6 +- docs/localization-parity.md | 10 +- docs/preferences.md | 2 +- 11 files changed, 2130 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a579491a8..26786ee9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had native-speaker review. ## Upcoming @@ -131,6 +131,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3abf336b0..9bcea9444 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Français", "fr"), ("Deutsch", "de"), ("Čeština", "cs"), + ("ქართული", "ka"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886fc..baf914619 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -766,6 +766,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Georgian too. + assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + let mut ka = Session { config: Some(store.clone()), ..Default::default() }; + ka.load_settings(); + assert_eq!(ka.prefs.general.language, "ka"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 315e2ba34..ed5de67cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,7 +13,7 @@ use effectcraft_engine::menus::{MenuEntry, MenuNode}; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; +const CATALOG_CODES: [&str; 11] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs", "ka"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -64,6 +64,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Georgian. +pub(crate) fn georgian(app: &EffectcraftApp) -> bool { + language(app) == "ka" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build follows the locales its page passes in ([`set_system_locales`]). @@ -98,7 +103,8 @@ fn first_supported(tags: &[String]) -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`, +/// `ka-GE` → `ka`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -109,6 +115,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ka" => "ka", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -137,6 +144,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Explorer-ში ჩვენება", + "Reveal in File Manager" => return "ფაილების მენეჯერში ჩვენება", + _ => {} + } + } lookup(language, command, source).unwrap_or(source) } @@ -165,6 +179,7 @@ fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "ka" => GEORGIAN, _ => return None, }; catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) @@ -190,6 +205,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". + "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), _ => return shown, }; match e.command.as_str() { @@ -299,6 +316,17 @@ pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> Stri return format!("Atribuir atalho à visualização 3D “{name}”"); } } + "ka" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("მალსახმობის მინიჭება სამუშაო სივრცისთვის „{name}“"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("მალსახმობის მინიჭება 3D ხედისთვის „{name}“"); + } + } _ => {} } label(app, "", &shown).into() @@ -6641,6 +6669,641 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), ]; +/// Georgian (ka): the same (command, English label) keys as `JAPANESE`, one entry per fixed menu row. +/// Original translations written from the meaning of the English labels, sharing the terminology of +/// the other Craft apps' Georgian catalogs (შრე, საკვანძო კადრი, კომპოზიცია, ნიღაბი); commands are +/// verbal nouns (შენახვა, გახსნა) and Georgian has no capital letters. +const GEORGIAN: &[(&str, &str, &str)] = &[ + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "EffectCraft-ის შესახებ..."), + ("", "Settings...", "პარამეტრები..."), + ("app.settings", "General...", "ზოგადი..."), + ("app.settings", "Startup & Repair...", "გაშვება და აღდგენა..."), + ("app.settings", "Project...", "პროექტი..."), + ("app.settings", "Composition...", "კომპოზიცია..."), + ("app.settings", "Previews...", "წინასწარი ნახვა..."), + ("app.settings", "Appearance...", "იერსახე..."), + ("app.settings", "Grids & Guides...", "ბადე და დამხმარე ხაზები..."), + ("app.settings", "Labels...", "იარლიყები..."), + ("app.settings", "Type...", "ტექსტი..."), + ("app.settings", "Import...", "იმპორტი..."), + ("app.settings", "Export...", "ექსპორტი..."), + ("app.settings", "Audio...", "აუდიო..."), + ("app.settings", "Disk...", "დისკი..."), + ("app.settings", "Memory & CPU...", "მეხსიერება და CPU..."), + ("app.settings", "Video...", "ვიდეო..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "სკრიპტები და ექსპრესიები..."), + ("app.hide", "Hide EffectCraft", "EffectCraft-ის დამალვა"), + ("app.hideOthers", "Hide Others", "სხვების დამალვა"), + ("app.showAll", "Show All", "ყველას ჩვენება"), + ("app.quit", "Quit EffectCraft", "EffectCraft-იდან გასვლა"), + ("", "File", "ფაილი"), + ("", "New", "ახალი"), + ("file.newProject", "New Project", "ახალი პროექტი"), + ("file.newFromTemplate", "New Project from Template...", "ახალი პროექტი შაბლონიდან..."), + ("project.newFolder", "New Folder", "ახალი საქაღალდე"), + ("file.open", "Open Project...", "პროექტის გახსნა..."), + ("", "Open Recent", "ბოლოს გახსნილი ფაილების გახსნა"), + ("file.clearRecent", "Clear Recent Projects", "ბოლო პროექტების სიის გასუფთავება"), + ("file.close", "Close", "დახურვა"), + ("file.closeProject", "Close Project", "პროექტის დახურვა"), + ("file.save", "Save", "შენახვა"), + ("", "Save As", "შენახვა როგორც"), + ("file.saveAs", "Save As...", "შენახვა როგორც..."), + ("file.saveCopy", "Save a Copy...", "ასლის შენახვა..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "ასლის შენახვა XML-ად..."), + ("templates.saveAs", "Save as Template...", "შაბლონად შენახვა..."), + ("file.incrementAndSave", "Increment and Save", "ვერსიის გაზრდით შენახვა"), + ("file.revert", "Revert", "წინა ვერსიაზე დაბრუნება"), + ("", "Import", "იმპორტი"), + ("file.import", "File...", "ფაილი..."), + ("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."), + ("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.importSolid", "Solid...", "სოლიდი..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("", "Import Recent Footage", "ბოლო ვიდეომასალის იმპორტი"), + ("file.clearRecentFootage", "Clear Recent Footage", "ბოლო ვიდეომასალის სიის გასუფთავება"), + ("", "Export", "ექსპორტი"), + ("renderQueue.add", "Add to Render Queue", "რენდერის რიგში დამატება"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("app.find", "Find", "ძებნა"), + ("layer.addItem", "Add Footage to Comp", "ვიდეომასალის კომპოზიციაში დამატება"), + ("file.newCompFromSelection", "New Comp from Selection...", "ახალი კომპოზიცია მონიშნულიდან..."), + ("", "Dependencies", "დამოკიდებულებები"), + ("file.collectFiles", "Collect Files...", "ფაილების შეგროვება..."), + ("file.consolidateFootage", "Consolidate All Footage", "მთელი ვიდეომასალის კონსოლიდაცია"), + ("file.removeUnusedFootage", "Remove Unused Footage", "გამოუყენებელი ვიდეომასალის წაშლა"), + ("file.reduceProject", "Reduce Project", "პროექტის შემცირება"), + ("file.findMissing", "Find Missing Effects", "დაკარგული ეფექტების ძებნა"), + ("file.findMissing", "Find Missing Fonts", "დაკარგული შრიფტების ძებნა"), + ("file.findMissing", "Find Missing Footage", "დაკარგული ვიდეომასალის ძებნა"), + ("file.relinkFootage", "Relink Missing Footage...", "დაკარგული ვიდეომასალის ხელახლა დაკავშირება..."), + ("file.watchFolder", "Watch Folder...", "საქაღალდეზე თვალყურის დევნება..."), + ("", "Scripts", "სკრიპტები"), + ("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."), + ("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."), + ("", "Create Proxy", "პროქსის შექმნა"), + ("file.createProxy", "Still...", "სტატიკური კადრი..."), + ("file.createProxy", "Movie...", "ვიდეო..."), + ("", "Set Proxy", "პროქსის მინიჭება"), + ("file.setProxy", "File...", "ფაილი..."), + ("file.setProxyNone", "None", "არცერთი"), + ("", "Interpret Footage", "ვიდეომასალის ინტერპრეტაცია"), + ("file.interpretFootage", "Main...", "ძირითადი..."), + ("file.interpretProxy", "Proxy...", "პროქსი..."), + ("file.rememberInterpretation", "Remember Interpretation", "ინტერპრეტაციის დამახსოვრება"), + ("file.applyInterpretation", "Apply Interpretation", "ინტერპრეტაციის გამოყენება"), + ("", "Replace Footage", "ვიდეომასალის ჩანაცვლება"), + ("file.replaceFootage", "File...", "ფაილი..."), + ("file.replaceWithPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.replaceWithSolid", "Solid...", "სოლიდი..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "შრეებიანი კომპოზიციით"), + ("file.reloadFootage", "Reload Footage", "ვიდეომასალის ხელახლა ჩატვირთვა"), + ("file.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("file.projectSettings", "Project Settings...", "პროექტის პარამეტრები..."), + ("app.quit", "Exit", "გასვლა"), + ("", "Edit", "რედაქტირება"), + ("edit.undo", "Undo", "დაბრუნება"), + ("edit.redo", "Redo", "ხელახლა შესრულება"), + ("", "History", "ისტორია"), + ("app.commandPalette", "Quick Apply...", "სწრაფი გამოყენება..."), + ("edit.cut", "Cut", "ამოჭრა"), + ("edit.copy", "Copy", "კოპირება"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "კოპირება თვისებების ბმულებით"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "კოპირება თვისებების ფარდობითი ბმულებით"), + ("edit.copyExpressionOnly", "Copy Expression Only", "მხოლოდ ექსპრესიის კოპირება"), + ("edit.paste", "Paste", "ჩასმა"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "საკვანძო კადრების შებრუნებული ჩასმა"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "ტექსტის ჩასმა ფორმატის შეთანხმებით"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "მხოლოდ ტექსტის ფორმატის ჩასმა"), + ("edit.clear", "Clear", "გასუფთავება"), + ("edit.duplicate", "Duplicate", "დუბლირება"), + ("edit.splitLayer", "Split Layer", "შრის დაყოფა"), + ("edit.liftWorkArea", "Lift Work Area", "სამუშაო არის აწევა"), + ("edit.extractWorkArea", "Extract Work Area", "სამუშაო არის ამოღება"), + ("edit.selectAll", "Select All", "ყველაფრის მონიშვნა"), + ("edit.deselectAll", "Deselect All", "ყველა მონიშვნის მოხსნა"), + ("", "Label", "იარლიყი"), + ("edit.selectLabelGroup", "Select Label Group", "იარლიყის ჯგუფის მონიშვნა"), + ("edit.label", "None", "არცერთი"), + ("edit.label", "Red", "წითელი"), + ("edit.label", "Yellow", "ყვითელი"), + ("edit.label", "Aqua", "ზღვისფერი"), + ("edit.label", "Pink", "ვარდისფერი"), + ("edit.label", "Lavender", "ლავანდისფერი"), + ("edit.label", "Peach", "ატმისფერი"), + ("edit.label", "Sea Foam", "ზღვის ქაფი"), + ("edit.label", "Blue", "ლურჯი"), + ("edit.label", "Green", "მწვანე"), + ("edit.label", "Purple", "იისფერი"), + ("edit.label", "Orange", "ნარინჯისფერი"), + ("edit.label", "Brown", "ყავისფერი"), + ("edit.label", "Fuchsia", "ფუქსია"), + ("edit.label", "Cyan", "ცისფერი"), + ("edit.label", "Sandstone", "ქვიშაქვა"), + ("edit.label", "Dark Green", "მუქი მწვანე"), + ("app.settings", "Edit Label Colors...", "იარლიყის ფერების რედაქტირება..."), + ("", "Select Keyframe Label Group", "საკვანძო კადრების იარლიყის ჯგუფის მონიშვნა"), + ("keys.selectLabelGroup", "On Selected Layers", "მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "On All Layers", "ყველა შრეზე"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "ხილული საკვანძო კადრები მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "ხილული საკვანძო კადრები ყველა შრეზე"), + ("", "Purge", "გაწმენდა"), + ("edit.purge", "All Cache...", "მთელი ქეში..."), + ("edit.purge", "All Memory & Disk Cache...", "მთელი მეხსიერება და დისკის ქეში..."), + ("edit.purge", "All Memory", "მთელი მეხსიერება"), + ("edit.purge", "All Disk Cache...", "მთელი დისკის ქეში..."), + ("edit.purge", "All 3D Cache...", "მთელი 3D ქეში..."), + ("edit.purgeUndo", "Undo", "დაბრუნების ისტორია"), + ("edit.purge", "Image Cache Memory", "გამოსახულების ქეშის მეხსიერება"), + ("edit.purge", "Snapshot", "მომენტალური ასლი"), + ("edit.editOriginal", "Edit Original...", "ორიგინალის რედაქტირება..."), + ("", "Templates", "შაბლონები"), + ("app.templates", "Render Settings...", "რენდერის პარამეტრები..."), + ("app.templates", "Output Module...", "გამოტანის მოდული..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "კლავიატურის მალსახმობები"), + ("", "Preferences", "პარამეტრები"), + ("", "Composition", "კომპოზიცია"), + ("comp.new", "New Composition...", "ახალი კომპოზიცია..."), + ("comp.settings", "Composition Settings...", "კომპოზიციის პარამეტრები..."), + ("comp.setPosterTime", "Set Poster Time", "პოსტერის დროის დაყენება"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "კომპოზიციის მოკვეთა სამუშაო არემდე"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "კომპოზიციის ჩამოჭრა ინტერესის არემდე"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "კომპოზიციის ჩამოჭრა მონიშნული შრ(ეებ)ის საზღვრებამდე"), + ("render.addOutputModule", "Add Output Module", "გამოტანის მოდულის დამატება"), + ("", "Preview", "წინასწარი ნახვა"), + ("playback.toggle", "Play Current Preview", "მიმდინარე წინასწარი ნახვის დაკვრა"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("playback.audio", "Audio", "აუდიო"), + ("", "Save Frame As", "კადრის შენახვა როგორც"), + ("comp.saveFrameAs", "File...", "ფაილი..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-ის შრეები..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "კადრის კოპირება გაცვლის ბუფერში"), + ("render.preRender", "Pre-render...", "წინასწარი რენდერი..."), + ("render.saveCurrentPreview", "Save Current Preview...", "მიმდინარე წინასწარი ნახვის შენახვა..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("", "Responsive Design — Time", "რესპონსიული დიზაინი — დრო"), + ("comp.responsiveTime", "Create Intro", "შესავლის შექმნა"), + ("comp.responsiveTime", "Create Outro", "დასასრულის შექმნა"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "დაცული არის შექმნა სამუშაო არიდან"), + ("comp.flowchart", "Composition Flowchart", "კომპოზიციის ბლოკ-სქემა"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "VR გარემოს შექმნა..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "კუბური რუკის ამოღება..."), + ("", "Layer", "შრე"), + ("layer.newText", "Text", "ტექსტი"), + ("layer.newSolid", "Solid...", "სოლიდი..."), + ("layer.newLight", "Light...", "სინათლე..."), + ("layer.newCamera", "Camera...", "კამერა..."), + ("layer.newNull", "Null Object", "ნულოვანი ობიექტი"), + ("layer.newShape", "Shape Layer", "ფიგურის შრე"), + ("layer.newAdjustment", "Adjustment Layer", "კორექციის შრე"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "შიგთავსის გათვალისწინებით შევსების შრე..."), + ("layer.new3dPrimitive", "Cube", "კუბი"), + ("layer.new3dPrimitive", "Sphere", "სფერო"), + ("layer.new3dPrimitive", "Plane", "სიბრტყე"), + ("layer.new3dPrimitive", "Torus", "ტორი"), + ("layer.new3dPrimitive", "Cone", "კონუსი"), + ("layer.new3dPrimitive", "Cylinder", "ცილინდრი"), + ("layer.settings", "Layer Settings...", "შრის პარამეტრები..."), + ("layer.openLayer", "Open Layer", "შრის გახსნა"), + ("layer.openSource", "Open Layer Source", "შრის წყაროს გახსნა"), + ("layer.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("", "Mask", "ნიღაბი"), + ("layer.addMask", "New Mask", "ახალი ნიღაბი"), + ("layer.mask.shape", "Mask Shape...", "ნიღბის ფორმა..."), + ("layer.mask.set", "Mask Feather...", "ნიღბის კიდეების შერბილება..."), + ("layer.mask.set", "Mask Opacity...", "ნიღბის გაუმჭვირვალობა..."), + ("layer.mask.set", "Mask Expansion...", "ნიღბის გაფართოება..."), + ("layer.mask.reset", "Reset Mask", "ნიღბის ჩამოყრა"), + ("layer.mask.remove", "Remove Mask", "ნიღბის წაშლა"), + ("layer.mask.removeAll", "Remove All Masks", "ყველა ნიღბის წაშლა"), + ("track.mask", "Track Mask", "ნიღბის ტრეკინგი"), + ("", "Mode", "რეჟიმი"), + ("layer.mask.mode", "None", "არცერთი"), + ("layer.mask.mode", "Add", "დამატება"), + ("layer.mask.mode", "Subtract", "გამოკლება"), + ("layer.mask.mode", "Intersect", "გადაკვეთა"), + ("layer.mask.mode", "Lighten", "გაღიავება"), + ("layer.mask.mode", "Darken", "ჩამუქება"), + ("layer.mask.mode", "Difference", "სხვაობა"), + ("layer.mask.invert", "Inverted", "შებრუნებული"), + ("layer.mask.lock", "Locked", "ჩაკეტილი"), + ("", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.mask.motionBlur", "Same As Layer", "შრის მსგავსად"), + ("layer.mask.motionBlur", "On", "ჩართული"), + ("layer.mask.motionBlur", "Off", "გამორთული"), + ("", "Feather Falloff", "კიდეების შერბილების მილევა"), + ("layer.mask.featherFalloff", "Smooth", "გლუვი"), + ("layer.mask.featherFalloff", "Linear", "წრფივი"), + ("layer.mask.unlockAll", "Unlock All Masks", "ყველა ნიღბის განბლოკვა"), + ("layer.mask.lockOthers", "Lock Other Masks", "სხვა ნიღბების ჩაკეტვა"), + ("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"), + ("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "შეკრული"), + ("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"), + ("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"), + ("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("", "Quality", "ხარისხი"), + ("layer.quality", "Best", "საუკეთესო"), + ("layer.quality", "Draft", "მონახაზი"), + ("layer.quality", "Wireframe", "კარკასი"), + ("layer.sampling", "Bilinear", "ბიწრფივი"), + ("layer.sampling", "Bicubic", "ბიკუბური"), + ("", "Switches", "გადამრთველები"), + ("layer.hideOtherVideo", "Hide Other Video", "სხვა ვიდეოს დამალვა"), + ("layer.showAllVideo", "Show All Video", "მთელი ვიდეოს ჩვენება"), + ("layer.unlockAll", "Unlock All Layers", "ყველა შრის განბლოკვა"), + ("layer.expressions", "Enable Expressions", "ექსპრესიების ჩართვა"), + ("layer.expressions", "Disable Expressions", "ექსპრესიების გამორთვა"), + ("layer.setSwitch", "Shy", "მორცხვი"), + ("layer.setSwitch", "Lock", "ჩაკეტვა"), + ("layer.setSwitch", "Audio", "აუდიო"), + ("layer.setSwitch", "Video", "ვიდეო"), + ("layer.setSwitch", "Solo", "სოლო"), + ("layer.setSwitch", "Effect", "ეფექტი"), + ("layer.setSwitch", "Collapse", "ჩაკეცვა"), + ("layer.setSwitch", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.setSwitch", "Adjustment Layer", "კორექციის შრე"), + ("", "Transform", "გარდაქმნა"), + ("layer.transform", "Reset", "ჩამოყრა"), + ("layer.setTransform", "Anchor Point...", "საყრდენი წერტილი..."), + ("layer.setTransform", "Position...", "პოზიცია..."), + ("layer.setTransform", "Scale...", "მასშტაბი..."), + ("layer.setTransform", "Orientation...", "ორიენტაცია..."), + ("layer.setTransform", "Rotation...", "შემობრუნება..."), + ("layer.setTransform", "Opacity...", "გაუმჭვირვალობა..."), + ("layer.transform", "Flip Horizontal", "ჰორიზონტალურად არეკვლა"), + ("layer.transform", "Flip Vertical", "ვერტიკალურად არეკვლა"), + ("layer.transform", "Center In View", "ხედის ცენტრში მოთავსება"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "საყრდენი წერტილის შრის შიგთავსის ცენტრში მოთავსება"), + ("layer.transform", "Fit to Comp", "კომპოზიციაში ჩატევა"), + ("layer.transform", "Fit to Comp Width", "კომპოზიციის სიგანეში ჩატევა"), + ("layer.transform", "Fit to Comp Height", "კომპოზიციის სიმაღლეში ჩატევა"), + ("layer.autoOrient", "Auto-Orient...", "ავტოორიენტაცია..."), + ("", "Time", "დრო"), + ("layer.enableTimeRemap", "Enable Time Remapping", "დროის გადანაწილების ჩართვა"), + ("layer.timeReverse", "Time-Reverse Layer", "შრის დროში შებრუნება"), + ("layer.timeStretch", "Time Stretch...", "დროში გაწელვა..."), + ("layer.freezeFrame", "Freeze Frame", "კადრის გაყინვა"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "ბოლო კადრზე გაყინვა"), + ("layer.alignVideoToData", "Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("", "Frame Blending", "კადრების შერევა"), + ("layer.frameBlending", "Off", "გამორთული"), + ("layer.frameBlending", "Frame Mix", "კადრების ნაზავი"), + ("layer.frameBlending", "Pixel Motion", "პიქსელების მოძრაობა"), + ("layer.setSwitch", "3D Layer", "3D შრე"), + ("layer.setSwitch", "Guide Layer", "დამხმარე შრე"), + ("layer.environment", "Environment Layer", "გარემოს შრე"), + ("", "Markers", "მარკერები"), + ("layer.addMarker", "Add Marker", "მარკერის დამატება"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "მარკერების განახლება წყაროდან"), + ("layer.markersLock", "Lock Markers", "მარკერების ჩაკეტვა"), + ("layer.deleteAllMarkers", "Delete All Markers", "ყველა მარკერის წაშლა"), + ("layer.setSwitch", "Preserve Transparency", "გამჭვირვალობის შენარჩუნება"), + ("", "Blending Mode", "შერევის რეჟიმი"), + ("layer.setBlendMode", "Normal", "ჩვეულებრივი"), + ("layer.setBlendMode", "Dissolve", "გაფანტვა"), + ("layer.setBlendMode", "Dancing Dissolve", "მოცეკვავე გაფანტვა"), + ("layer.setBlendMode", "Darken", "ჩამუქება"), + ("layer.setBlendMode", "Multiply", "გამრავლება"), + ("layer.setBlendMode", "Color Burn", "ფერით ჩამუქება"), + ("layer.setBlendMode", "Classic Color Burn", "კლასიკური ფერით ჩამუქება"), + ("layer.setBlendMode", "Linear Burn", "წრფივი ჩამუქება"), + ("layer.setBlendMode", "Darker Color", "უფრო მუქი ფერი"), + ("layer.setBlendMode", "Add", "დამატება"), + ("layer.setBlendMode", "Lighten", "გაღიავება"), + ("layer.setBlendMode", "Screen", "ეკრანი"), + ("layer.setBlendMode", "Color Dodge", "ფერით გაღიავება"), + ("layer.setBlendMode", "Classic Color Dodge", "კლასიკური ფერით გაღიავება"), + ("layer.setBlendMode", "Linear Dodge", "წრფივი გაღიავება"), + ("layer.setBlendMode", "Lighter Color", "უფრო ღია ფერი"), + ("layer.setBlendMode", "Overlay", "გადაფარვა"), + ("layer.setBlendMode", "Soft Light", "რბილი სინათლე"), + ("layer.setBlendMode", "Hard Light", "მკვეთრი სინათლე"), + ("layer.setBlendMode", "Linear Light", "წრფივი სინათლე"), + ("layer.setBlendMode", "Vivid Light", "კაშკაშა სინათლე"), + ("layer.setBlendMode", "Pin Light", "წერტილოვანი სინათლე"), + ("layer.setBlendMode", "Hard Mix", "მკვეთრი შერევა"), + ("layer.setBlendMode", "Difference", "სხვაობა"), + ("layer.setBlendMode", "Classic Difference", "კლასიკური სხვაობა"), + ("layer.setBlendMode", "Exclusion", "გამორიცხვა"), + ("layer.setBlendMode", "Subtract", "გამოკლება"), + ("layer.setBlendMode", "Divide", "გაყოფა"), + ("layer.setBlendMode", "Hue", "ფერის ტონი"), + ("layer.setBlendMode", "Saturation", "გაჯერება"), + ("layer.setBlendMode", "Color", "ფერი"), + ("layer.setBlendMode", "Luminosity", "ნათება"), + ("layer.setBlendMode", "Stencil Alpha", "ალფა-ტრაფარეტი"), + ("layer.setBlendMode", "Stencil Luma", "ლუმა-ტრაფარეტი"), + ("layer.setBlendMode", "Silhouette Alpha", "ალფა-სილუეტი"), + ("layer.setBlendMode", "Silhouette Luma", "ლუმა-სილუეტი"), + ("layer.setBlendMode", "Alpha Add", "ალფას დამატება"), + ("layer.setBlendMode", "Luminescent Premul", "მანათობელი წინასწარ გამრავლებული"), + ("layer.setBlendMode", "Next Blending Mode", "შემდეგი შერევის რეჟიმი"), + ("layer.setBlendMode", "Previous Blending Mode", "წინა შერევის რეჟიმი"), + ("", "Track Matte", "ტრეკ-მატი"), + ("layer.trackMatte", "No Track Matte", "ტრეკ-მატის გარეშე"), + ("layer.trackMatte", "Alpha Matte", "ალფა-მატი"), + ("layer.trackMatte", "Alpha Inverted Matte", "შებრუნებული ალფა-მატი"), + ("layer.trackMatte", "Luma Matte", "ლუმა-მატი"), + ("layer.trackMatte", "Luma Inverted Matte", "შებრუნებული ლუმა-მატი"), + ("layer.trackMatte", "Matte with Layer Above", "მატი ზედა შრით"), + ("layer.trackMatte", "Matte with Layer Below", "მატი ქვედა შრით"), + ("", "Layer Styles", "შრის სტილები"), + ("layer.style.options", "Layer Style Options...", "შრის სტილის პარამეტრები..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "რედაქტირებად სტილებად გარდაქმნა"), + ("layer.style.showAll", "Show All", "ყველას ჩვენება"), + ("layer.style.removeAll", "Remove All", "ყველას წაშლა"), + ("layer.style.dropShadow", "Drop Shadow", "ჩრდილი"), + ("layer.style.innerShadow", "Inner Shadow", "შიდა ჩრდილი"), + ("layer.style.outerGlow", "Outer Glow", "გარე ნათება"), + ("layer.style.innerGlow", "Inner Glow", "შიდა ნათება"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "ფასკა და რელიეფი"), + ("layer.style.satin", "Satin", "ატლასი"), + ("layer.style.colorOverlay", "Color Overlay", "ფერის გადაფარვა"), + ("layer.style.gradientOverlay", "Gradient Overlay", "გრადიენტის გადაფარვა"), + ("layer.style.stroke", "Stroke", "კონტური"), + ("path.groupShapes", "Group Shapes", "ფიგურების დაჯგუფება"), + ("path.ungroupShapes", "Ungroup Shapes", "ფიგურების დაჯგუფების მოხსნა"), + ("", "Arrange", "დალაგება"), + ("layer.arrange", "Bring Layer to Front", "შრის წინა პლანზე გადმოტანა"), + ("layer.arrange", "Bring Layer Forward", "შრის წინ გადმოტანა"), + ("layer.arrange", "Send Layer Backward", "შრის უკან გადატანა"), + ("layer.arrange", "Send Layer to Back", "შრის უკანა პლანზე გადატანა"), + ("", "Reveal", "ჩვენება"), + ("layer.revealSource", "Reveal Layer Source in Project", "შრის წყაროს ჩვენება პროექტში"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "შრის ჩვენება პროექტის ბლოკ-სქემაში"), + ("comp.revealInProject", "Reveal Composition in Project", "კომპოზიციის ჩვენება პროექტში"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "ექსპრესიის შეცდომების ჩვენება"), + ("", "Create", "შექმნა"), + ("layer.create", "Convert to Editable Text", "რედაქტირებად ტექსტად გარდაქმნა"), + ("layer.create", "Create Shapes from Text", "ფიგურების შექმნა ტექსტიდან"), + ("layer.create", "Create Masks from Text", "ნიღბების შექმნა ტექსტიდან"), + ("layer.create", "Create Shapes from Vector Layer", "ფიგურების შექმნა ვექტორული შრიდან"), + ("layer.create", "Create Keyframes from Data", "საკვანძო კადრების შექმნა მონაცემებიდან"), + ("layer.create", "Null Controllers for Positional Points", "ნულოვანი მართვის ობიექტები პოზიციის წერტილებისთვის"), + ("layer.create", "Null Controllers for Path Points", "ნულოვანი მართვის ობიექტები ტრაექტორიის წერტილებისთვის"), + ("layer.create", "Nulls Following Path Points", "ტრაექტორიის წერტილებს მიმყოლი ნულოვანი ობიექტები"), + ("layer.create", "Null Tracing Along Path", "ტრაექტორიის გასწვრივ მოძრავი ნულოვანი ობიექტი"), + ("layer.create", "Create 3D Layer Instance", "3D შრის ეგზემპლარის შექმნა"), + ("", "Camera", "კამერა"), + ("camera.fromView", "Create Camera from 3D View", "კამერის შექმნა 3D ხედიდან"), + ("camera.stereoRig", "Create Stereo 3D Rig", "სტერეო 3D სისტემის შექმნა"), + ("camera.orbitNull", "Create Orbit Null", "ორბიტის ნულოვანი ობიექტის შექმნა"), + ("camera.fromModel", "Create Cameras from 3D Model", "კამერების შექმნა 3D მოდელიდან"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "ფოკუსური მანძილის მიბმა ინტერესის წერტილზე"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "ფოკუსური მანძილის მიბმა შრეზე"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "ფოკუსური მანძილის დაყენება შრეზე"), + ("layer.cameraSettings", "Camera Settings...", "კამერის პარამეტრები..."), + ("view.reset3DView", "Reset 3D View", "3D ხედის ჩამოყრა"), + ("", "Light", "სინათლე"), + ("light.fromModel", "Create Lights from 3D Model", "სინათლის წყაროების შექმნა 3D მოდელიდან"), + ("light.controlWithCamera", "Control Light with Camera", "სინათლის მართვა კამერით"), + ("light.environmentBackground", "Create Environment Light Background Layer", "გარემოს სინათლის ფონური შრის შექმნა"), + ("", "Material", "მასალა"), + ("material.revealSource", "Reveal Material Source in Project", "მასალის წყაროს ჩვენება პროექტში"), + ("material.reset", "Reset Material", "მასალის ჩამოყრა"), + ("material.duplicateAssign", "Duplicate and Assign Material", "მასალის დუბლირება და მინიჭება"), + ("layer.autoTrace", "Auto-trace...", "ავტომატური ვექტორიზაცია..."), + ("layer.precompose", "Pre-compose...", "კომპოზიციაში მოთავსება..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "სცენების ჭრილების ამოცნობა..."), + ("", "Effect", "ეფექტი"), + ("window.panel", "Effect Controls", "ეფექტის მართვა"), + ("effect.applyLast", "Last Effect", "ბოლო ეფექტი"), + ("effect.removeAll", "Remove All", "ყველას წაშლა"), + ("effect.manage", "Manage Effects...", "ეფექტების მართვა..."), + ("effect.plugins.load", "Load Effect Plug-in...", "ეფექტის პლაგინის ჩატვირთვა..."), + ("", "Animation", "ანიმაცია"), + ("anim.savePreset", "Save Animation Preset...", "ანიმაციის პრესეტის შენახვა..."), + ("anim.applyPreset", "Apply Animation Preset...", "ანიმაციის პრესეტის გამოყენება..."), + ("", "Recent Animation Presets", "ბოლო ანიმაციის პრესეტები"), + ("anim.clearRecentPresets", "Clear Recent Presets", "ბოლო პრესეტების სიის გასუფთავება"), + ("anim.browsePresets", "Browse Presets...", "პრესეტების დათვალიერება..."), + ("", "Text Animation Presets", "ტექსტის ანიმაციის პრესეტები"), + ("layer.applyTextPreset", "Typewriter", "საბეჭდი მანქანა"), + ("layer.applyTextPreset", "Fade Up Characters", "ასოების ამოსვლა გამოჩენით"), + ("layer.applyTextPreset", "Bounce In Words", "სიტყვების ხტუნვით შემოსვლა"), + ("layer.applyTextPreset", "Tracking In", "ასოების შეკუმშვით შემოსვლა"), + ("layer.applyTextPreset", "Scramble", "არევა"), + ("layer.applyTextPreset", "Blur In", "გამოჩენა გაბუნდოვნებიდან"), + ("layer.applyTextPreset", "Jitter", "კანკალი"), + ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), + ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), + ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), + ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), + ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "აუდიოს საკვანძო კადრებად გარდაქმნა"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "ექსპრესიის საკვანძო კადრებად გარდაქმნა"), + ("keys.easyEase", "Easy Ease", "რბილი შენელება"), + ("keys.easyEaseIn", "Easy Ease In", "რბილი შენელება შესვლისას"), + ("keys.easyEaseOut", "Easy Ease Out", "რბილი შენელება გამოსვლისას"), + ("keys.exponentialScale", "Exponential Scale", "ექსპონენციალური მასშტაბი"), + ("keys.rpfCameraImport", "RPF Camera Import", "RPF კამერის იმპორტი"), + ("layer.sequence", "Sequence Layers...", "შრეების თანმიმდევრობით განლაგება..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "საკვანძო კადრების დროში შებრუნება"), + ("", "Animate Text", "ტექსტის ანიმირება"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("layer.addTextAnimator", "Anchor Point", "საყრდენი წერტილი"), + ("layer.addTextAnimator", "Position", "პოზიცია"), + ("layer.addTextAnimator", "Scale", "მასშტაბი"), + ("layer.addTextAnimator", "Skew", "დახრა"), + ("layer.addTextAnimator", "Rotation", "შემობრუნება"), + ("layer.addTextAnimator", "Opacity", "გაუმჭვირვალობა"), + ("layer.addTextAnimator", "All Transform Properties", "გარდაქმნის ყველა თვისება"), + ("", "Fill Color", "შევსების ფერი"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "ფერის ტონი"), + ("layer.addTextAnimator", "Saturation", "გაჯერება"), + ("layer.addTextAnimator", "Brightness", "სიკაშკაშე"), + ("", "Stroke Color", "კონტურის ფერი"), + ("layer.addTextAnimator", "Stroke Width", "კონტურის სისქე"), + ("", "Front", "წინა მხარე"), + ("", "Bevel", "წაკვეთა"), + ("", "Side", "გვერდითი მხარე"), + ("", "Back", "უკანა მხარე"), + ("layer.addTextAnimator", "Color", "ფერი"), + ("layer.addTextAnimator", "Tracking", "ასოთაშორისი ინტერვალი"), + ("layer.addTextAnimator", "Line Anchor", "სტრიქონის საყრდენი"), + ("layer.addTextAnimator", "Line Spacing", "სტრიქონთა დაშორება"), + ("layer.addTextAnimator", "Character Offset", "ასოს წანაცვლება"), + ("layer.addTextAnimator", "Character Value", "ასოს მნიშვნელობა"), + ("layer.addTextAnimator", "Blur", "გაბუნდოვნება"), + ("text.animatorFontAxes", "Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("", "Add Text Selector", "ტექსტის სელექტორის დამატება"), + ("text.addSelector", "Range", "დიაპაზონი"), + ("text.addSelector", "Wiggly", "რხევითი"), + ("text.addSelector", "Expression", "ექსპრესია"), + ("text.removeAllAnimators", "Remove All Text Animators", "ტექსტის ყველა ანიმატორის წაშლა"), + ("prop.setExpression", "Add Expression", "ექსპრესიის დამატება"), + ("essential.addProperty", "Add Property to Essential Graphics", "თვისების დამატება ძირითად გრაფიკაში"), + ("prop.separateDimensions", "Separate Dimensions", "განზომილებების გაყოფა"), + ("track.camera", "Track Camera", "კამერის ტრეკინგი"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "მოძრაობის ტრეკინგი"), + ("track.stabilize", "Stabilize Motion", "მოძრაობის სტაბილიზაცია"), + ("track.property", "Track this Property", "ამ თვისების ტრეკინგი"), + ("anim.reveal", "Reveal Properties with Keyframes", "საკვანძო კადრების მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal Properties with Animation", "ანიმაციის მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal All Modified Properties", "ყველა შეცვლილი თვისების ჩვენება"), + ("", "View", "ხედი"), + ("view.newViewer", "New Viewer", "ახალი დამთვალიერებელი"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "დაყოფა ახალი ჩაკეტილი დამთვალიერებელით"), + ("view.zoomIn", "Zoom In", "გადიდება"), + ("view.zoomOut", "Zoom Out", "დაპატარავება"), + ("", "Resolution", "გარჩევადობა"), + ("view.res.full", "Full", "სრული"), + ("view.res.half", "Half", "ნახევარი"), + ("view.res.third", "Third", "მესამედი"), + ("view.res.quarter", "Quarter", "მეოთხედი"), + ("view.res.custom", "Custom...", "მორგებული..."), + ("view.displayColorManagement", "Use Display Color Management", "ეკრანის ფერთა მართვის გამოყენება"), + ("", "Simulate Output", "გამოტანის იმიტაცია"), + ("view.simulateOutput", "No Output Simulation", "გამოტანის იმიტაციის გარეშე"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "ძველი Macintosh RGB (გამა 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "წრფივი (გამა 1.0)"), + ("view.customRgb", "My Custom RGB...", "ჩემი მორგებული RGB..."), + ("view.simulateOutput", "Custom...", "მორგებული..."), + ("view.rulers", "Show Rulers", "სახაზავების ჩვენება"), + ("", "Panel Background Color", "პანელის ფონის ფერი"), + ("view.panelBackground", "Black", "შავი"), + ("view.panelBackground", "Dark Gray", "მუქი ნაცრისფერი"), + ("view.panelBackground", "Medium Gray (Default)", "საშუალო ნაცრისფერი (ნაგულისხმევი)"), + ("view.panelBackground", "Light Gray", "ღია ნაცრისფერი"), + ("view.panelBackground", "White", "თეთრი"), + ("view.panelBackground", "Custom", "მორგებული"), + ("view.panelBackground", "Select Custom Background Color...", "მორგებული ფონის ფერის არჩევა..."), + ("view.guides", "Show Guides", "დამხმარე ხაზების ჩვენება"), + ("view.snapToGuides", "Snap to Guides", "დამხმარე ხაზებზე მიბმა"), + ("view.lockGuides", "Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("view.addGuide", "Add Guide...", "დამხმარე ხაზის დამატება..."), + ("view.addPercentGuide", "Add Percentage Guide...", "პროცენტული დამხმარე ხაზის დამატება..."), + ("view.convertGuides", "Convert Guides to Percentage", "დამხმარე ხაზების პროცენტულად გარდაქმნა"), + ("view.convertGuides", "Convert Guides to Pixels", "დამხმარე ხაზების პიქსელებად გარდაქმნა"), + ("view.clearGuides", "Clear Guides", "დამხმარე ხაზების გასუფთავება"), + ("view.importGuides", "Import Guides...", "დამხმარე ხაზების იმპორტი..."), + ("view.exportGuides", "Export Guides...", "დამხმარე ხაზების ექსპორტი..."), + ("view.grid", "Show Grid", "ბადის ჩვენება"), + ("view.snapToGrid", "Snap to Grid", "ბადეზე მიბმა"), + ("view.snapping", "Snapping", "მიბმა"), + ("view.options", "View Options...", "ხედის პარამეტრები..."), + ("view.layerControls", "Show Layer Controls", "შრის მართვის ელემენტების ჩვენება"), + ("", "Switch View Layout", "ხედების განლაგების გადართვა"), + ("view.layout", "1 View", "1 ხედი"), + ("view.layout", "2 Views", "2 ხედი"), + ("view.layout", "4 Views", "4 ხედი"), + ("view.shareViewOptions", "Share View Options", "ხედის პარამეტრების გაზიარება"), + ("", "Switch 3D View", "3D ხედის გადართვა"), + ("view.3d.activeCamera", "Active Camera", "აქტიური კამერა"), + ("view.3d.default", "Default", "ნაგულისხმევი"), + ("view.3d.front", "Front", "წინიდან"), + ("view.3d.left", "Left", "მარცხნიდან"), + ("view.3d.top", "Top", "ზემოდან"), + ("view.3d.back", "Back", "უკნიდან"), + ("view.3d.right", "Right", "მარჯვნიდან"), + ("view.3d.bottom", "Bottom", "ქვემოდან"), + ("view.3d.custom1", "Custom View 1", "მორგებული ხედი 1"), + ("view.3d.custom2", "Custom View 2", "მორგებული ხედი 2"), + ("view.3d.custom3", "Custom View 3", "მორგებული ხედი 3"), + ("", "Assign Shortcut to 3D View", "მალსახმობის მინიჭება 3D ხედისთვის"), + ("view.3d.last", "Switch to Last 3D View", "ბოლო 3D ხედზე გადართვა"), + ("view.lookAtSelected", "Look at Selected Layers", "მონიშნული შრეების დათვალიერება"), + ("view.lookAtAll", "Look at All Layers", "ყველა შრის დათვალიერება"), + ("time.set", "Go to Time...", "დროზე გადასვლა..."), + ("view.fullScreen", "Enter Full Screen", "სრულეკრანიან რეჟიმში გადასვლა"), + ("", "Window", "ფანჯარა"), + ("", "Workspace", "სამუშაო სივრცე"), + ("window.workspace", "Default", "ნაგულისხმევი"), + ("window.workspace", "Review", "განხილვა"), + ("window.workspace", "Learn", "სწავლა"), + ("window.workspace", "Small Screen", "პატარა ეკრანი"), + ("window.workspace", "Standard", "სტანდარტული"), + ("window.workspace", "All Panels", "ყველა პანელი"), + ("window.workspace", "Animation", "ანიმაცია"), + ("window.workspace", "Color", "ფერი"), + ("window.workspace", "Effects", "ეფექტები"), + ("window.workspace", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.workspace", "Minimal", "მინიმალური"), + ("window.workspace", "Motion Tracking", "მოძრაობის ტრეკინგი"), + ("window.workspace", "Paint", "ხატვა"), + ("window.workspace", "Text", "ტექსტი"), + ("window.workspace", "Undocked Panels", "მოხსნილი პანელები"), + ("window.resetWorkspace", "Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("window.saveWorkspace", "Save Changes to this Workspace", "ამ სამუშაო სივრცეში ცვლილებების შენახვა"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "ახალ სამუშაო სივრცედ შენახვა..."), + ("window.editWorkspaces", "Edit Workspaces...", "სამუშაო სივრცეების რედაქტირება..."), + ("", "Assign Shortcut to Workspace", "მალსახმობის მინიჭება სამუშაო სივრცისთვის"), + ("window.panel", "Align", "სწორება"), + ("window.panel", "Audio", "აუდიო"), + ("window.panel", "Brushes", "ფუნჯები"), + ("window.panel", "Character", "სიმბოლო"), + ("window.panel", "Content-Aware Fill", "შიგთავსის გათვალისწინებით შევსება"), + ("window.panel", "Effects & Presets", "ეფექტები და პრესეტები"), + ("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.panel", "Info", "ინფორმაცია"), + ("help.inAppTutorials", "Learn", "სწავლა"), + ("window.panel", "Lumetri Scopes", "Lumetri Scopes"), + ("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"), + ("window.panel", "Media Browser", "მედიის ბრაუზერი"), + ("window.panel", "Metadata", "მეტამონაცემები"), + ("window.panel", "Motion Sketch", "მოძრაობის ესკიზი"), + ("window.panel", "Paint", "ხატვა"), + ("window.panel", "Paragraph", "აბზაცი"), + ("window.panel", "Preview", "წინასწარი ნახვა"), + ("window.panel", "Progress", "პროგრესი"), + ("window.panel", "Properties", "თვისებები"), + ("window.panel", "Script Console", "სკრიპტის კონსოლი"), + ("window.panel", "Smoother", "დამგლუვებელი"), + ("window.panel", "Tools", "ხელსაწყოები"), + ("window.panel", "Tracker", "ტრეკერი"), + ("window.panel", "Wiggler", "რხევის გენერატორი"), + ("window.panel", "Composition", "კომპოზიცია"), + ("window.panel", "Flowchart", "ბლოკ-სქემა"), + ("window.panel", "Footage", "ვიდეომასალა"), + ("window.panel", "Layer", "შრე"), + ("window.panel", "Project", "პროექტი"), + ("window.panel", "Render Queue", "რენდერის რიგი"), + ("window.panel", "Timeline", "დროის ხაზი"), + ("window.panel", "Create Nulls From Paths", "ნულოვანი ობიექტების შექმნა ტრაექტორიებიდან"), + ("window.panel", "VR Comp Editor", "VR კომპოზიციის რედაქტორი"), + ("", "Help", "დახმარება"), + ("help.docs", "EffectCraft Help...", "EffectCraft-ის დახმარება..."), + ("help.docs", "Scripting Help...", "სკრიპტების დახმარება..."), + ("help.docs", "Expression Reference...", "ექსპრესიების ცნობარი..."), + ("help.docs", "Effect Reference...", "ეფექტების ცნობარი..."), + ("anim.browsePresets", "Animation Presets...", "ანიმაციის პრესეტები..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "კლავიატურის მალსახმობები..."), + ("help.inAppTutorials", "In-App Tutorials...", "აპის შიგნით გაკვეთილები..."), + ("help.onlineTutorials", "Online Tutorials...", "ონლაინ გაკვეთილები..."), + ("help.systemReport", "System Compatibility Report...", "სისტემის თავსებადობის ანგარიში..."), + ("help.enableLogging", "Enable Logging", "ჟურნალის ჩართვა"), + ("help.revealLogFile", "Reveal Logging File", "ჟურნალის ფაილის ჩვენება"), + ("help.discord", "Join the ArtCraft Discord...", "შემოუერთდით ArtCraft-ის Discord-ს..."), + ("help.reportIssue", "Provide Feedback...", "გამოხმაურების გაგზავნა..."), + ("help.website", "ArtCraft Website", "ArtCraft-ის ვებსაიტი"), + ("help.appPage", "EffectCraft Home Page", "EffectCraft-ის საწყისი გვერდი"), + ("help.github", "EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("file.openDemoProject", "Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), +]; + #[cfg(test)] mod tests { use super::*; @@ -6753,6 +7416,22 @@ mod tests { } } + #[test] + fn georgian_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Finder-ში ჩვენება"), + ("Reveal in Explorer", "Explorer-ში ჩვენება"), + ("Reveal in File Manager", "ფაილების მენეჯერში ჩვენება"), + ] { + assert_eq!(label(&app, command, source), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + } + } + fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -6784,6 +7463,7 @@ mod tests { let mut french_keys = BTreeSet::new(); let mut german_keys = BTreeSet::new(); let mut czech_keys = BTreeSet::new(); + let mut georgian_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), @@ -6795,6 +7475,7 @@ mod tests { (FRENCH, &mut french_keys), (GERMAN, &mut german_keys), (CZECH, &mut czech_keys), + (GEORGIAN, &mut georgian_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -6825,6 +7506,7 @@ mod tests { assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); + assert_eq!(japanese_keys, georgian_keys, "the languages must translate the same entries"); } /// Exercise each engine spelling on every host: a macOS-only run must still catch a @@ -6911,6 +7593,9 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("ka"), "ka"), + (Some("ka-GE"), "ka"), + (Some("ka_GE.UTF-8"), "ka"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -6944,9 +7629,15 @@ mod tests { #[test] fn every_translated_entry_keeps_the_english_shape() { - for (code, catalog) in - [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)] - { + for (code, catalog) in [ + ("zh-hans", SIMPLIFIED_CHINESE), + ("zh-hant", TRADITIONAL_CHINESE), + ("uk", UKRAINIAN), + ("es", SPANISH), + ("pt-br", PORTUGUESE), + ("cs", CZECH), + ("ka", GEORGIAN), + ] { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); @@ -7190,6 +7881,44 @@ mod tests { assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Atribuir atalho à visualização 3D “Top”"); } + #[test] + fn ka_translates_menus_without_changing_bindings_or_user_names() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); + let en = crate::native_menu::build(&app); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ka"})).unwrap(); + assert_eq!(language(&app), "ka"); + assert!(georgian(&app)); + assert!(!japanese(&app)); + let ka = crate::native_menu::build(&app); + let bindings = |menu: &crate::native_menu::NativeMenu| { + menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() + }; + assert_eq!(bindings(&en), bindings(&ka)); + assert_eq!(label(&app, "", "Composition"), "კომპოზიცია"); + assert_eq!(label(&app, "", "Layer"), "შრე"); + assert_eq!(label(&app, "comp.new", "New Composition..."), "ახალი კომპოზიცია..."); + assert_eq!(label(&app, "anim.addKeyframe", "Add Keyframe"), "საკვანძო კადრის დამატება"); + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + assert!(ka.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "რბილი შენელება")); + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "დაბრუნება შეუძლებელია"); + app.session.history.undo.push(("Comp 1".into(), app.session.project.clone())); + assert_eq!(entry(&app, &undo, "Undo Comp 1".into()), "დაბრუნება: Comp 1"); + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &panel, "Layer: File".into()), "შრე: File"); + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), + "მალსახმობის მინიჭება სამუშაო სივრცისთვის „Default“" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "მალსახმობის მინიჭება 3D ხედისთვის „Top“"); + // A dynamic filename is never translated. + app.session.prefs.push_recent("/tmp/File.ecproj"); + assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + assert!(effectcraft_engine::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3ab115582..f670e63a8 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -70,12 +70,14 @@ fn catalog(language: &str) -> Option<&'static Catalog> { static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() }; static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() }; static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() }; + static GEORGIAN_CATALOG: Catalog = Catalog { rows: GEORGIAN, categories: GEORGIAN_CATEGORIES, index: OnceLock::new() }; match language { "zh-hans" => Some(&SIMPLIFIED), "zh-hant" => Some(&TRADITIONAL), // Japanese: the slot the Japanese data point on #821 asked for, so both languages share // this one catalog instead of inventing a second mechanism. "ja" => Some(&JAPANESE), + "ka" => Some(&GEORGIAN_CATALOG), _ => None, } } @@ -447,6 +449,346 @@ const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; /// Japanese (`ja`) category labels, keyed by the registry's English category. const JAPANESE_CATEGORIES: &[(&str, &str)] = &[]; +/// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning +/// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian +/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`, +/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as +/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix. +const GEORGIAN: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "სიღრმის მატი"), + ("ec.3d.depthoffield", "Depth of Field", "ველის სიღრმე"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.audio.backwards", "Backwards", "უკუღმა"), + ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), + ("ec.audio.compressor", "Compressor", "კომპრესორი"), + ("ec.audio.delay", "Delay", "დაყოვნება"), + ("ec.audio.distortion", "Distortion", "დამახინჯება"), + ("ec.audio.flangechorus", "Flange & Chorus", "ფლენჯერი და ქორუსი"), + ("ec.audio.gate", "Gate", "გეიტი"), + ("ec.audio.highlowpass", "High-Low Pass", "მაღალი/დაბალი სიხშირეების ფილტრი"), + ("ec.audio.modulator", "Modulator", "მოდულატორი"), + ("ec.audio.parametriceq", "Parametric EQ", "პარამეტრული ეკვალაიზერი"), + ("ec.audio.reverb", "Reverb", "რევერბი"), + ("ec.audio.stereomixer", "Stereo Mixer", "სტერეომიქშერი"), + ("ec.audio.tone", "Tone", "ტონი"), + ("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"), + ("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"), + ("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"), + ("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"), + ("ec.blur.fastbox", "Fast Box Blur", "სწრაფი ბლოკური გაბუნდოვნება"), + ("ec.blur.gaussian", "Gaussian Blur", "გაუსის გაბუნდოვნება"), + ("ec.blur.radial", "Radial Blur", "რადიალური გაბუნდოვნება"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "ნახევარკადრული ციმციმის შემცირება"), + ("ec.blur.sharpen", "Sharpen", "სიმკვეთრე"), + ("ec.blur.smart", "Smart Blur", "ჭკვიანი გაბუნდოვნება"), + ("ec.blur.unsharp", "Unsharp Mask", "არამკვეთრი ნიღაბი"), + ("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"), + ("ec.channel.blend", "Blend", "შერევა"), + ("ec.channel.calculations", "Calculations", "გამოთვლები"), + ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"), + ("ec.channel.invert", "Invert", "ინვერსია"), + ("ec.channel.minimax", "Minimax", "მინიმაქსი"), + ("ec.channel.removecolormatting", "Remove Color Matting", "ფერადი მატინგის მოცილება"), + ("ec.channel.setchannels", "Set Channels", "არხების დაყენება"), + ("ec.channel.setmatte", "Set Matte", "მატის დაყენება"), + ("ec.channel.shiftchannels", "Shift Channels", "არხების გადანაცვლება"), + ("ec.channel.solidcomposite", "Solid Composite", "სოლიდთან კომპოზიტი"), + ("ec.color.autocolor", "Auto Color", "ავტოფერი"), + ("ec.color.autocontrast", "Auto Contrast", "ავტოკონტრასტი"), + ("ec.color.autolevels", "Auto Levels", "ავტოდონეები"), + ("ec.color.blackwhite", "Black & White", "შავ-თეთრი"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"), + ("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "ფერის შეცვლა"), + ("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"), + ("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"), + ("ec.color.colorama", "Colorama", "კოლორამა"), + ("ec.color.colorbalance", "Color Balance", "ფერთა ბალანსი"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "ფერთა ბალანსი (HLS)"), + ("ec.color.colorlink", "Color Link", "ფერის ბმა"), + ("ec.color.colorstabilizer", "Color Stabilizer", "ფერის სტაბილიზატორი"), + ("ec.color.curves", "Curves", "მრუდები"), + ("ec.color.equalize", "Equalize", "გათანაბრება"), + ("ec.color.exposure", "Exposure", "ექსპოზიცია"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "გამა/საფეხური/გაძლიერება"), + ("ec.color.huesaturation", "Hue/Saturation", "ფერის ტონი/გაჯერება"), + ("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"), + ("ec.color.levels", "Levels", "დონეები"), + ("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"), + ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO ფაილის გარდაქმნა"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO ვიზუალური სტილის გარდაქმნა"), + ("ec.color.photofilter", "Photo Filter", "ფოტოფილტრი"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS ნებისმიერი რუკა"), + ("ec.color.selectivecolor", "Selective Color", "შერჩევითი ფერი"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "ჩრდილები/ღია ტონები"), + ("ec.color.tint", "Tint", "ტონირება"), + ("ec.color.tritone", "Tritone", "სამტონიანი"), + ("ec.color.vibrance", "Vibrance", "სიხალასე"), + ("ec.color.videolimiter", "Video Limiter", "ვიდეოლიმიტერი"), + ("ec.control.angle", "Angle Control", "კუთხის მართვა"), + ("ec.control.checkbox", "Checkbox Control", "მოსანიშნი ველის მართვა"), + ("ec.control.color", "Color Control", "ფერის მართვა"), + ("ec.control.dropdown", "Dropdown Menu Control", "ჩამოსაშლელი მენიუს მართვა"), + ("ec.control.layer", "Layer Control", "შრის მართვა"), + ("ec.control.point", "Point Control", "წერტილის მართვა"), + ("ec.control.point3d", "3D Point Control", "3D წერტილის მართვა"), + ("ec.control.slider", "Slider Control", "სლაიდერის მართვა"), + ("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"), + ("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"), + ("ec.distort.bulge", "Bulge", "გამობერილობა"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"), + ("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"), + ("ec.distort.liquify", "Liquify", "გათხევადება"), + ("ec.distort.magnify", "Magnify", "გადიდება"), + ("ec.distort.meshwarp", "Mesh Warp", "ბადისებრი დეფორმაცია"), + ("ec.distort.mirror", "Mirror", "სარკე"), + ("ec.distort.offset", "Offset", "წანაცვლება"), + ("ec.distort.opticscompensation", "Optics Compensation", "ოპტიკის კომპენსაცია"), + ("ec.distort.polar", "Polar Coordinates", "პოლარული კოორდინატები"), + ("ec.distort.puppet", "Puppet", "მარიონეტი"), + ("ec.distort.reshape", "Reshape", "ფორმის შეცვლა"), + ("ec.distort.ripple", "Ripple", "ტალღა"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "მცოცავი ჩამკეტის გასწორება"), + ("ec.distort.smear", "Smear", "გადღაბნა"), + ("ec.distort.spherize", "Spherize", "სფერიზება"), + ("ec.distort.transform", "Transform", "გარდაქმნა"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "ტურბულენტური წანაცვლება"), + ("ec.distort.twirl", "Twirl", "გრიგალი"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "გრიგალი (მოძველებული)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "გადიდება დეტალების შენარჩუნებით"), + ("ec.distort.warp", "Warp", "დეფორმაცია"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "დეფორმაციის სტაბილიზატორი"), + ("ec.distort.wavewarp", "Wave Warp", "ტალღური დეფორმაცია"), + ("ec.generate.advancedlightning", "Advanced Lightning", "გაფართოებული ელვა"), + ("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"), + ("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"), + ("ec.generate.beam", "Beam", "სხივი"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"), + ("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"), + ("ec.generate.circle", "Circle", "წრე"), + ("ec.generate.ellipse", "Ellipse", "ელიფსი"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "პიპეტით შევსება"), + ("ec.generate.fill", "Fill", "შევსება"), + ("ec.generate.fourcolor", "4-Color Gradient", "4-ფერიანი გრადიენტი"), + ("ec.generate.fractal", "Fractal", "ფრაქტალი"), + ("ec.generate.gradientramp", "Gradient Ramp", "გრადიენტული გადასვლა"), + ("ec.generate.grid", "Grid", "ბადე"), + ("ec.generate.lensflare", "Lens Flare", "ობიექტივის ანარეკლი"), + ("ec.generate.paintbucket", "Paint Bucket", "საღებავის ვედრო"), + ("ec.generate.radiowaves", "Radio Waves", "რადიოტალღები"), + ("ec.generate.scribble", "Scribble", "ჯღაბნა"), + ("ec.generate.stroke", "Stroke", "კონტური"), + ("ec.generate.vegas", "Vegas", "ვეგასი"), + ("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"), + ("ec.key.colorkey", "Color Key", "ფერის გასაღები"), + ("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"), + ("ec.key.differencematte", "Difference Matte", "სხვაობის მატი"), + ("ec.key.extract", "Extract", "ამოღება"), + ("ec.key.innerouter", "Inner/Outer Key", "შიდა/გარე გასაღები"), + ("ec.key.keycleaner", "Key Cleaner", "გასაღების გამწმენდი"), + ("ec.key.linearcolor", "Linear Color Key", "წრფივი ფერის გასაღები"), + ("ec.key.luma", "Luma Key", "სიკაშკაშის გასაღები"), + ("ec.key.screen", "Screen Key", "ეკრანის გასაღები"), + ("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"), + ("ec.key.unmult", "Unmult", "Unmult"), + ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"), + ("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"), + ("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"), + ("ec.matte.refinesoft", "Refine Soft Matte", "რბილი მატის დახვეწა"), + ("ec.matte.rotobrush", "Roto Brush & Refine Edge", "როტო-ფუნჯი და კიდის დახვეწა"), + ("ec.matte.simplechoker", "Simple Choker", "მარტივი შემკუმშველი"), + ("ec.noise.addgrain", "Add Grain", "მარცვლოვნების დამატება"), + ("ec.noise.curlnoise", "Curl Noise", "გრიგალური ხმაური"), + ("ec.noise.dustscratches", "Dust & Scratches", "მტვერი და ნაკაწრები"), + ("ec.noise.fractal", "Fractal Noise", "ფრაქტალური ხმაური"), + ("ec.noise.matchgrain", "Match Grain", "მარცვლოვნების მისადაგება"), + ("ec.noise.median", "Median", "მედიანა"), + ("ec.noise.medianlegacy", "Median (Legacy)", "მედიანა (მოძველებული)"), + ("ec.noise.noise", "Noise", "ხმაური"), + ("ec.noise.noisealpha", "Noise Alpha", "ალფა-ხმაური"), + ("ec.noise.noisehls", "Noise HLS", "HLS ხმაური"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "HLS ხმაური (ავტომატური)"), + ("ec.noise.removegrain", "Remove Grain", "მარცვლოვნების მოცილება"), + ("ec.noise.turbulent", "Turbulent Noise", "ტურბულენტური ხმაური"), + ("ec.obsolete.basic3d", "Basic 3D", "საბაზო 3D"), + ("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"), + ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"), + ("ec.obsolete.lightning", "Lightning", "ელვა"), + ("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"), + ("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"), + ("ec.paint.paint", "Paint", "ხატვა"), + ("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"), + ("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), + ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), + ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"), + ("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"), + ("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"), + ("ec.sim.caustics", "Caustics", "კაუსტიკა"), + ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), + ("ec.sim.cchair", "CC Hair", "CC Hair"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.foam", "Foam", "ქაფი"), + ("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"), + ("ec.sim.shatter", "Shatter", "დამსხვრევა"), + ("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"), + ("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"), + ("ec.stylize.emboss", "Emboss", "რელიეფი"), + ("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"), + ("ec.stylize.glow", "Glow", "ნათება"), + ("ec.stylize.mosaic", "Mosaic", "მოზაიკა"), + ("ec.stylize.motiontile", "Motion Tile", "მოძრავი ფილები"), + ("ec.stylize.posterize", "Posterize", "პოსტერიზაცია"), + ("ec.stylize.roughenedges", "Roughen Edges", "კიდეების დაუხეშება"), + ("ec.stylize.scatter", "Scatter", "გაბნევა"), + ("ec.stylize.strobe", "Strobe Light", "სტრობოსკოპი"), + ("ec.stylize.texturize", "Texturize", "ტექსტურირება"), + ("ec.stylize.threshold", "Threshold", "ზღვარი"), + ("ec.text.numbers", "Numbers", "რიცხვები"), + ("ec.text.timecode", "Timecode", "თაიმკოდი"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), + ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.echo", "Echo", "ექო"), + ("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"), + ("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"), + ("ec.time.timedifference", "Time Difference", "დროითი სხვაობა"), + ("ec.time.timedisplacement", "Time Displacement", "დროითი წანაცვლება"), + ("ec.time.timewarp", "Timewarp", "Timewarp"), + ("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"), + ("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), + ("ec.transition.cctwister", "CC Twister", "CC Twister"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"), + ("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"), + ("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"), + ("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"), + ("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"), + ("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"), + ("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"), + ("ec.utility.facetrackpoints", "Face Track Points", "სახის ტრეკინგის წერტილები"), + ("ec.utility.growbounds", "Grow Bounds", "საზღვრების გაზრდა"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR კომპანდერი"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ღია ტონების შეკუმშვა"), + ("ec.vr.blur", "VR Blur", "VR გაბუნდოვნება"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR ქრომატული აბერაციები"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR ფერადი გრადიენტები"), + ("ec.vr.converter", "VR Converter", "VR გარდამქმნელი"), + ("ec.vr.denoise", "VR De-Noise", "VR ხმაურის მოცილება"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR ციფრული ხარვეზი"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR ფრაქტალური ხმაური"), + ("ec.vr.glow", "VR Glow", "VR ნათება"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR სიბრტყიდან სფეროზე"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR სფეროს შემობრუნება"), + ("ec.vr.sharpen", "VR Sharpen", "VR სიმკვეთრე"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR სფეროდან სიბრტყეზე"), +]; + +/// Georgian (`ka`) category labels, keyed by the registry's English category. +const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[ + ("3D Channel", "3D არხი"), + ("Audio", "აუდიო"), + ("Blur & Sharpen", "გაბუნდოვნება და სიმკვეთრე"), + ("Channel", "არხი"), + ("Color Correction", "ფერის კორექცია"), + ("Distort", "დამახინჯება"), + ("Expression Controls", "ექსპრესიის მართვა"), + ("Generate", "გენერაცია"), + ("Immersive Video", "იმერსიული ვიდეო"), + ("Keying", "კეიინგი"), + ("Matte", "მატი"), + ("Noise & Grain", "ხმაური და მარცვლოვნება"), + ("Obsolete", "მოძველებული"), + ("Paint", "ხატვა"), + ("Perspective", "პერსპექტივა"), + ("Simulation", "სიმულაცია"), + ("Stylize", "სტილიზაცია"), + ("Text", "ტექსტი"), + ("Time", "დრო"), + ("Transition", "გადასვლა"), + ("Utility", "დამხმარე"), +]; + /// The 21 category folder names the generated Effect menu and Effects & Presets draw, in /// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese /// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where @@ -554,7 +896,9 @@ mod tests { for category in &categories { assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); } - for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] { + for (language, rows) in + [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES), ("ka", GEORGIAN_CATEGORIES)] + { for (english, translated) in rows { assert!(categories.contains(english), "{language} lists {english}, which is not a category"); assert!(!translated.is_empty(), "empty {language} translation for {english}"); @@ -562,6 +906,26 @@ mod tests { } } + /// Georgian has a row for every effect the zh-hans catalog has, in the same order and naming + /// the same effect, and a label for every category; repeats and renames keep their index. + #[test] + fn georgian_names_every_effect_and_category() { + let hans: Vec<(&str, &str)> = SIMPLIFIED_CHINESE.iter().map(|(id, en, _)| (*id, *en)).collect(); + let ka: Vec<(&str, &str)> = GEORGIAN.iter().map(|(id, en, _)| (*id, *en)).collect(); + assert_eq!(hans, ka, "the Georgian effect rows differ from the zh-hans ones"); + assert!(GEORGIAN.iter().all(|(_, _, translated)| !translated.is_empty()), "an empty Georgian effect name"); + for category in effects::categories() { + assert!(GEORGIAN_CATEGORIES.iter().any(|(english, _)| *english == category), "no Georgian row for category {category}"); + } + set_current("ka"); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "გაუსის გაბუნდოვნება"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "გაუსის გაბუნდოვნება 2"); + assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur"); + assert_eq!(category("Blur & Sharpen"), "გაბუნდოვნება და სიმკვეთრე"); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "გაუსის")); + set_current("en"); + } + /// A repeated effect carries the index the engine appends to the group name; a name the user /// typed in Effect Controls stays verbatim. #[test] diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a271aeb1..4ac96fd18 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -47,6 +47,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), @@ -55,6 +56,8 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + // Georgian also reads its own rows for text the other catalogs don't have yet. + "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -4200,6 +4203,959 @@ const SPANISH: &[(&str, &str)] = &[ ("Project", "Proyecto"), ]; +/// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). +const GEORGIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "იერსახის პარამეტრები…"), + ("Add to favorites", "რჩეულებში დამატება"), + ("Remove from favorites", "რჩეულებიდან ამოშლა"), + (" OK ", " OK "), + (" Delete ", " წაშლა "), + (" Open Auto-Save ", " ავტოშენახვის გახსნა "), + ("(missing)", "(აკლია)"), + ("360 View Orientation", "360° ხედის ორიენტაცია"), + ("3:2 Pulldown: {}", "3:2 Pulldown: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "სუფთა ოთახის მეთოდით შექმნილი, ღია კოდის კომპოზიტორი მოძრავი გრაფიკისა და ვიზუალური ეფექტებისთვის: ნატიურად მუშაობს macOS-ზე, Windows-სა და Linux-ზე, ასევე ბრაუზერში. ArtCraft-ის შემოქმედებითი აპების ოჯახის ნაწილი.", + ), + ("About EffectCraft", "EffectCraft-ის შესახებ"), + ("About...", "პროგრამის შესახებ..."), + ("Active", "აქტიური"), + ("Active Camera", "აქტიური კამერა"), + ("Alpha", "ალფა"), + ("Adapt Feature On Every Frame", "მახასიათებლის განახლება ყოველ კადრზე"), + ("Adaptive Sample Limit:", "ადაპტური ნიმუშების ზღვარი:"), + ("Add Animator", "ანიმატორის დამატება"), + ("Add Comment", "კომენტარის დამატება"), + ("Add Formatting", "ფორმატირების დამატება"), + ("Add Group", "ჯგუფის დამატება"), + ("Add Mirror", "სარკის დამატება"), + ("Add Selected As", "მონიშნულის დამატება როგორც"), + ("Add a comment", "კომენტარის დამატება"), + ("Add a shape, fill, stroke or path operation", "ფიგურის, შევსების, კონტურის ან ტრაექტორიის ოპერაციის დამატება"), + ("Add to Composition", "კომპოზიციაში დამატება"), + ("Add to Favorites", "რჩეულებში დამატება"), + ("Add:", "დამატება:"), + ("Adjust Exposure (stops)", "ექსპოზიციის კორექცია (საფეხურები)"), + ("Adjust composition duration to the time span of the selected layers", "კომპოზიციის ხანგრძლივობის მორგება მონიშნული შრეების დროის შუალედზე"), + ("Advanced 3D", "გაფართოებული 3D"), + ("Align Layers to:", "შრეების სწორება:"), + ("All six face cameras turn together; the scene is not changed.", "სახეების ექვსივე კამერა ერთად ბრუნავს; სცენა არ იცვლება."), + ("Anchor:", "მიმაგრება:"), + ("Aperture", "დიაფრაგმა"), + ("Appearance Mode", "იერსახის რეჟიმი"), + ("Appearance Mode: {}", "იერსახის რეჟიმი: {}"), + ("Apply Dimensions:", "განზომილებების გამოყენება:"), + ("Apply Motion To:", "მოძრაობის გამოყენება:"), + ("Arrange", "დალაგება"), + ("Assign", "მინიჭება"), + ("Auto", "ავტომატური"), + ("Auto-saves", "ავტოშენახვები"), + ("Back", "უკან"), + ("Background Color:", "ფონის ფერი:"), + ("Blend Mode:", "შერევის რეჟიმი:"), + ("Blending Mode", "შერევის რეჟიმი"), + ("Blur", "გაბუნდოვნება"), + ("Blur Level", "გაბუნდოვნების დონე"), + ("Boundary", "საზღვარი"), + ("Browser Storage", "ბრაუზერის მეხსიერება"), + ("Browse…", "არჩევა…"), + ("Brush Dynamics", "ფუნჯის დინამიკა"), + ("Caching frames…", "კადრების ქეშირება…"), + ("Cancel", "გაუქმება"), + ("Casts Shadows", "ჩრდილს აგდებს"), + ("Change Renderer…", "რენდერერის შეცვლა…"), + ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), + ("Channel", "არხი"), + ("Channel:", "არხი:"), + ("Chapter:", "თავი:"), + ("Choose...", "არჩევა..."), + ("Classic 3D", "კლასიკური 3D"), + ("Clear", "გასუფთავება"), + ("Clear list", "სიის გასუფთავება"), + ("Click a key to see its commands.", "დაწკაპეთ კლავიშზე მისი ბრძანებების სანახავად."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "დაწკაპეთ ზოლის ზემოთ გაუმჭვირვალობის გადასვლის წერტილის დასამატებლად, ქვემოთ — ფერის გადასვლის წერტილის დასამატებლად.", + ), + ("Click adds a point (½ height = no change)", "დაწკაპუნება ამატებს წერტილს (½ სიმაღლე = ცვლილების გარეშე)"), + ("Click adds a point; drag one off to remove", "დაწკაპუნება ამატებს წერტილს; მის მოსაშორებლად გადაათრიეთ გარეთ"), + ("Click to make this viewer active", "დაწკაპეთ ამ დამთვალიერებელის გასააქტიურებლად"), + ("Click to reveal the expression in the timeline", "დაწკაპეთ ექსპრესიის დროის ხაზზე საჩვენებლად"), + ("Click to show the active composition here", "დაწკაპეთ აქტიური კომპოზიციის აქ საჩვენებლად"), + ("Click, then click in the Composition panel", "დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Clone Options", "კლონირების პარამეტრები"), + ("Clone Source Overlay:", "კლონირების წყაროს გადაფარვა:"), + ("Close", "დახურვა"), + ("Close Panel", "პანელის დახურვა"), + ("Close tutorial", "გაკვეთილის დახურვა"), + ("Color", "ფერი"), + ("Color Depth: {}", "ფერის სიღრმე: {}"), + ("Color:", "ფერი:"), + ("Colour map", "ფერების რუკა"), + ("Columns", "სვეტები"), + ("Comment:", "კომენტარი:"), + ("Community", "საზოგადოება"), + ("Composition", "კომპოზიცია"), + ("Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("Composition Name:", "კომპოზიციის სახელი:"), + ("Composition Settings ▸ 3D Renderer", "კომპოზიციის პარამეტრები ▸ 3D რენდერერი"), + ("Composition Settings…", "კომპოზიციის პარამეტრები…"), + ("Composition marker bin: drag a marker to the time ruler", "კომპოზიციის მარკერების ბინი: გადაათრიეთ მარკერი დროის სახაზავზე"), + ("Cone Angle", "კონუსის კუთხე"), + ("Cone Feather", "კონუსის კიდეების შერბილება"), + ("Constrain Proportions", "პროპორციების შენარჩუნება"), + ("Continuous (Lock Outgoing to Incoming)", "უწყვეტი (გამავალის მიბმა შემავალზე)"), + ("Cue Point", "მინიშნების წერტილი"), + ("Current Render", "მიმდინარე რენდერი"), + ("Dark", "ბნელი"), + ("Dark Theme", "ბნელი თემა"), + ("Darker", "უფრო მუქი"), + ("Default Output:", "ნაგულისხმევი გამოტანა:"), + ("Default: {}", "ნაგულისხმევი: {}"), + ("Defaults", "ნაგულისხმევი"), + ("Delete", "წაშლა"), + ("Delete Selected Points", "მონიშნული წერტილების წაშლა"), + ("Delete selected project items", "პროექტის მონიშნული ელემენტების წაშლა"), + ("Delete this template", "ამ შაბლონის წაშლა"), + ("Depth of Field", "ველის სიღრმე"), + ("Discord", "Discord"), + ("Distribute Layers:", "შრეების განაწილება:"), + ("Don't Recover", "არ აღადგინო"), + ("Don't Save", "არ შეინახო"), + ("Double-click a layer to open it in the Layer panel", "ორჯერ დაწკაპეთ შრეზე მის შრის პანელში გასახსნელად"), + ("Double-click footage in the Project panel to open it here", "ორჯერ დაწკაპეთ ვიდეომასალაზე პროექტის პანელში მის აქ გასახსნელად"), + ("Double-click to edit the comment", "ორჯერ დაწკაპეთ კომენტარის რედაქტირებისთვის"), + ("Download", "ჩამოტვირთვა"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "გადმოათრიეთ თვისებები დროის ხაზიდან,\nან გამოიყენეთ ანიმაცია > თვისების დამატება ძირითად გრაფიკაში.", + ), + ("Drag to change", "გადაათრიეთ შესაცვლელად"), + ("Drag to create a guide", "გადაათრიეთ დამხმარე ხაზის შესაქმნელად"), + ("Drag to slip the source", "გადაათრიეთ წყაროს გადასაწევად"), + ("Drop Frame", "კადრების გამოტოვებით"), + ("Duplicate", "დუბლირება"), + ("Duration:", "ხანგრძლივობა:"), + ("Edit Gradient…", "გრადიენტის რედაქტირება…"), + ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), + ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), + ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), + ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), + ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), + ("Effects: {}", "ეფექტები: {}"), + ("Empty folder", "ცარიელი საქაღალდე"), + ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), + ("Enable Expression", "ექსპრესიის ჩართვა"), + ("Enhance", "გაუმჯობესება"), + ("Essential Properties — {}", "ძირითადი თვისებები — {}"), + ("Event", "მოვლენა"), + ("Export Template…", "შაბლონის ექსპორტი…"), + ("Export...", "ექსპორტი..."), + ("Expression Language Menu", "ექსპრესიების ენის მენიუ"), + ("Expression pick whip: drag onto a property", "ექსპრესიის ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Expression:", "ექსპრესია:"), + ("Eyedropper: click, then click in the Composition panel", "პიპეტი: დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Falloff", "მილევა"), + ("Falloff Distance", "მილევის მანძილი"), + ("Favorites", "რჩეულები"), + ("Field Render: {}", "ნახევარკადრების რენდერი: {}"), + ("Figures", "ციფრები"), + ("Fill Target", "შევსების სამიზნე"), + ("Finished", "დასრულდა"), + ("Flowchart: {}", "ბლოკ-სქემა: {}"), + ("Focus Distance", "ფოკუსური მანძილი"), + ("Follow Feature Rotation and Scale", "მახასიათებლის შემობრუნებისა და მასშტაბის მიდევნება"), + ("Footage: {}", "ვიდეომასალა: {}"), + ("Format: {}", "ფორმატი: {}"), + ("Frame Aspect Ratio: {}", "კადრის თანაფარდობა: {}"), + ("Frame Blending: {}", "კადრების შერევა: {}"), + ("Frame Rate:", "კადრების სიხშირე:"), + ("Frame Render Time {}ms", "კადრის რენდერის დრო {}ms"), + ("Frame Target:", "კადრის სამიზნე:"), + ("Freeze", "გაყინვა"), + ("Grab bag", "სხვადასხვა"), + ("Gradient Editor", "გრადიენტის რედაქტორი"), + ("Grid", "ბადე"), + ("Guide Layers: {}", "დამხმარე შრეები: {}"), + ("Guides", "დამხმარე ხაზები"), + ("Height:", "სიმაღლე:"), + ("Hide This", "ამის დამალვა"), + ("Histogram", "ჰისტოგრამა"), + ("Hold In Place", "ადგილზე დაფიქსირება"), + ("Home", "საწყისი ეკრანი"), + ("If Confidence is Below", "თუ სანდოობა ნაკლებია"), + ("Import", "იმპორტი"), + ("Import and Add to Composition", "იმპორტი და კომპოზიციაში დამატება"), + ("Import...", "იმპორტი..."), + ("Imported media", "იმპორტირებული მედია"), + ("Influence:", "გავლენა:"), + ("Install from File…", "ფაილიდან დაყენება…"), + ("Intensity", "ინტენსივობა"), + ("Inverted", "შებრუნებული"), + ("Join the ArtCraft community on Discord", "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე"), + ("Key: {}", "გასაღები: {}"), + ("LEARN", "სწავლა"), + ("Label", "იარლიყი"), + ("Label Colors and Names:", "იარლიყების ფერები და სახელები:"), + ("Label:", "იარლიყი:"), + ("Latest auto-save: {}", "ბოლო ავტოშენახვა: {}"), + ("Layer", "შრე"), + ("Layer controls", "შრის მართვის ელემენტები"), + ("Layer {}", "შრე {}"), + ("Layer:", "შრე:"), + ("Layer: {}", "შრე: {}"), + ("Learn", "სწავლა"), + ("Leave all attributes in \"{}\"", "ყველა ატრიბუტის დატოვება „{}“-ში"), + ("Licence: {}", "ლიცენზია: {}"), + ("Ligatures", "ლიგატურები"), + ("Light", "ღია"), + ("Light Theme", "ღია თემა"), + ("Light Type", "სინათლის ტიპი"), + ("Lighter", "უფრო ღია"), + ("Link Selected Property", "მონიშნული თვისების დაკავშირება"), + ("Location:", "პოზიცია:"), + ("Lock Aspect Ratio to {}", "თანაფარდობის ჩაკეტვა: {}"), + ("Lock to Zoom", "მასშტაბზე მიბმა"), + ("Locked: {} — {}", "ჩაკეტილი: {} — {}"), + ("Log:", "ჟურნალი:"), + ("Loop (Trace Path)", "მარყუჟი (ტრაექტორიის გავლა)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • შრიფტები: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "გადიდების კოეფიციენტის მენიუ"), + ("Make Comp Size", "კომპოზიციის ზომაზე მორგება"), + ("Markers", "მარკერები"), + ("Masks", "ნიღბები"), + ("Maximize Panel", "პანელის გაშლა"), + ("Media Replacement", "მედიის ჩანაცვლება"), + ("Mesh:", "ბადე:"), + ("Diameter:", "დიამეტრი:"), + ("Method Used: {} Average Error: {} pixels", "გამოყენებული მეთოდი: {} საშუალო ცდომილება: {} პიქსელი"), + ("Models folder: {}", "მოდელების საქაღალდე: {}"), + ("Modifiers:", "მოდიფიკატორები:"), + ("More", "მეტი"), + ("More ArtCraft apps", "ArtCraft-ის სხვა აპლიკაციები"), + ("More tutorials", "მეტი გაკვეთილი"), + ("Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("Motion Blur: {}", "მოძრაობის გაბუნდოვნება: {}"), + ("Motion Sketch: drag to record", "მოძრაობის ესკიზი: გადაათრიეთ ჩასაწერად"), + ("Move Down", "ჩამოწევა"), + ("Move Up", "აწევა"), + ("Move to Project Root", "პროექტის ძირში გადატანა"), + ("Name", "სახელი"), + ("Name:", "სახელი:"), + ("Navigation", "ნავიგაცია"), + ("New", "ახალი"), + ("New Comp from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Replace with Precomposition", "ჩადგმული კომპოზიციით ჩანაცვლება"), + ("New Duration:", "ახალი ხანგრძლივობა:"), + ("New composition name:", "ახალი კომპოზიციის სახელი:"), + ("New from Render Queue item:", "ახალი რენდერის რიგის ელემენტიდან:"), + ("New from Template", "შაბლონიდან შექმნა"), + ("Next", "შემდეგი"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "ყოჩაღ. აირჩიეთ სხვა გაკვეთილი საწყისი ეკრანის „სწავლის“ ჩანართზე."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "VR გარემო ჯერ არ არის. შექმენით: კომპოზიცია ▸ VR ▸ VR გარემოს შექმნა…", + ), + ("No audio layer selected", "აუდიოშრე არ არის მონიშნული"), + ("No background jobs running", "ფონური ამოცანები არ მიმდინარეობს"), + ("No composition markers", "კომპოზიციის მარკერები არ არის"), + ("No contributor data was built into this copy.", "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული."), + ( + "No device list from this host; the system default output is used.", + "ამ ჰოსტიდან მოწყობილობების სია მიუწვდომელია; გამოიყენება სისტემის ნაგულისხმევი გამოტანა.", + ), + ("No layer selected", "შრე არ არის მონიშნული"), + ("No matching effects or presets", "შესაბამისი ეფექტები ან პრესეტები არ მოიძებნა"), + ("No matching fonts", "შესაბამისი შრიფტები არ მოიძებნა"), + ("Variable fonts only", "მხოლოდ ცვლადი შრიფტები"), + ("No model credits were built into this copy.", "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული."), + ("No nested compositions", "ჩადგმული კომპოზიციები არ არის"), + ("No shortcut", "მალსახმობის გარეშე"), + ("No unlocked layer selected", "განბლოკილი შრე არ არის მონიშნული"), + ("No video", "ვიდეოს გარეშე"), + ("None", "არცერთი"), + ("Not applied: tick it in the list to add it.", "არ არის გამოყენებული: დასამატებლად მონიშნეთ სიაში."), + ("Not assigned", "არ არის მინიჭებული"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "შენიშვნა: ჩრდილს აგდებს მხოლოდ ის შრე, რომელსაც ჩართული აქვს „ჩრდილს აგდებს“."), + ("Notify", "შეტყობინება"), + ("OK", "OK"), + ("Offset:", "წანაცვლება:"), + ("On ({}) stop:", "ჩართვისას ({}) გაჩერება:"), + ("One-Node Camera", "ერთკვანძიანი კამერა"), + ("Opacity:", "გაუმჭვირვალობა:"), + ("Open Composition", "კომპოზიციის გახსნა"), + ("used once", "გამოყენებულია ერთხელ"), + ("used {} times", "გამოყენებულია {}-ჯერ"), + ("Compositions that use this item", "კომპოზიციები, რომლებიც ამ ელემენტს იყენებს"), + ("Open Last Saved Project", "ბოლოს შენახული პროექტის გახსნა"), + ("Open New Composition", "ახალი კომპოზიციის გახსნა"), + ("Open Output", "გამოტანის გახსნა"), + ("Open a composition to build Essential Graphics", "გახსენით კომპოზიცია ძირითადი გრაფიკის ასაგებად"), + ("Open a composition to see its scopes", "გახსენით კომპოზიცია მისი სკოპების სანახავად"), + ("Open a composition to track motion.", "გახსენით კომპოზიცია მოძრაობის ტრეკინგისთვის."), + ("Opus Tuning: {}", "Opus-ის მორგება: {}"), + ("Original Duration:", "საწყისი ხანგრძლივობა:"), + ("Output Cycle: {}", "გამოტანის ციკლი: {}"), + ("Output To:", "გამოტანის ადგილი:"), + ("Overlay", "გადაფარვა"), + ("Parameter Name:", "პარამეტრის სახელი:"), + ("Parent pick whip: drag onto a layer", "მშობლის ბმის სპირალი: გადაათრიეთ შრეზე"), + ("Pixel Aspect Ratio:", "პიქსელის გვერდების თანაფარდობა:"), + ("Playing on Video Preview", "იკვრება ვიდეოს წინასწარ ნახვაზე"), + ("Post-Render Action: {}", "რენდერის შემდგომი მოქმედება: {}"), + ("Preserve frame rate when nested or in render queue", "კადრების სიხშირის შენარჩუნება ჩადგმისას ან რენდერის რიგში"), + ("Preserve resolution when nested", "გარჩევადობის შენარჩუნება ჩადგმისას"), + ("Preset", "პრესეტი"), + ("Preset:", "პრესეტი:"), + ("Presets", "პრესეტები"), + ("Previous", "წინა"), + ("Primary:", "ძირითადი:"), + ("Process Before Match", "დამუშავება შეთანხმებამდე"), + ("Profile: {}", "პროფილი: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "პროექტის ფერის სიღრმე: დაწკაპეთ 8/16/32 bpc-ის გადასართავად, Alt-დაწკაპუნება — პროექტის პარამეტრები", + ), + ("Project: {}", "პროექტი: {}"), + ("Projects", "პროექტები"), + ("Property pick whip: drag onto a property", "თვისების ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Protected Region", "დაცული არე"), + ("Proxy Use: {}", "პროქსის გამოყენება: {}"), + ("Push All to Comp", "ყველაფრის კომპოზიციაში გადატანა"), + ("RGB", "RGB"), + ("Radius", "რადიუსი"), + ("Rate Control: {}", "სიჩქარის კონტროლი: {}"), + ("Recent", "ბოლოდროინდელი"), + ("Recording", "ჩაწერა"), + ("Remove", "წაშლა"), + ("Rename", "სახელის შეცვლა"), + ("Render: {} ms • UI {} fps{}", "რენდერი: {} ms • UI {} fps{}"), + ("Renderer:", "რენდერერი:"), + ("Request Persistent Storage", "მუდმივი მეხსიერების მოთხოვნა"), + ("Reset", "ჩამოყრა"), + ("Reset Labels", "იარლიყების ჩამოყრა"), + ("Reset Preset", "პრესეტის ჩამოყრა"), + ("Reset to Default", "ნაგულისხმევზე დაბრუნება"), + ("Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("Resolution/Down Sample Factor Popup", "გარჩევადობის/დისკრეტიზაციის კოეფიციენტის მენიუ"), + ("Restore Panel Size", "პანელის ზომის აღდგენა"), + ("Reverse the order", "რიგის შებრუნება"), + ("Revert All", "ყველაფრის დაბრუნება"), + ("Roman Hanging Punctuation", "ლათინური გამოწეული პუნქტუაცია"), + ("Roving:", "მცურავი:"), + ("Ruler zero point (drag to move, double-click to reset)", "სახაზავის ნულოვანი წერტილი (გადაათრიეთ გადასაადგილებლად, ორჯერ დაწკაპეთ ჩამოსაყრელად)"), + ("Rulers", "სახაზავები"), + ("Run", "გაშვება"), + ("Sample Format: {}", "ნიმუშის ფორმატი: {}"), + ("Samples Per Frame:", "ნიმუშები კადრზე:"), + ("Save from #{}", "შენახვა #{}-დან"), + ("Saved {}", "შენახულია {}"), + ("Script… (⌘/Ctrl+Enter runs)", "სკრიპტი… (⌘/Ctrl+Enter უშვებს)"), + ("Search commands or shortcuts", "ბრძანებების ან მალსახმობების ძიება"), + ("Select", "მონიშვნა"), + ("Select a command to change its shortcut.", "მონიშნეთ ბრძანება მისი მალსახმობის შესაცვლელად."), + ("Select a layer to see its effects", "მონიშნეთ შრე მისი ეფექტების სანახავად"), + ("Select a layer to see its properties", "მონიშნეთ შრე მისი თვისებების სანახავად"), + ("({}) Selected Objects", "({}) მონიშნული ობიექტი"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "მონიშნეთ ნიღბიანი ან ფიგურის ტრაექტორიის მქონე შრე (ან ტრაექტორიის თვისება), ან მარიონეტის სამაგრები.", + ), + ("Select a text layer", "მონიშნეთ ტექსტური შრე"), + ("Select an item in the Project panel", "მონიშნეთ ელემენტი პროექტის პანელში"), + ("Select animated properties to show them in the Graph Editor", "მონიშნეთ ანიმირებული თვისებები გრაფიკების რედაქტორში საჩვენებლად"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "მონიშნეთ წამყვანი სამაგრი, შემდეგ Shift-დაწკაპუნებით მონიშნეთ მიმყოლი სამაგრები (თმა, ქსოვილი, კუდები)", + ), + ("Set Ground Plane and Origin", "მიწის სიბრტყისა და სათავის დაყენება"), + ("Set Paint Colors to Black and White (D)", "ხატვის ფერების შავ-თეთრზე დაყენება (D)"), + ("Settings", "პარამეტრები"), + ("Settings Name:", "პარამეტრების სახელი:"), + ("Shadow Darkness", "ჩრდილის სიმუქე"), + ("Shadow Diffusion", "ჩრდილის გაბნევა"), + ("Shadows", "ჩრდილები"), + ("Shape…", "ფორმა…"), + ("Show", "ჩვენება"), + ("Show Layer Name", "შრის სახელის ჩვენება"), + ("Show Source Name", "წყაროს სახელის ჩვენება"), + ("Show me", "მაჩვენე"), + ("Show:", "ჩვენება:"), + ("Shutter Angle:", "ობტურატორის კუთხე:"), + ("Shutter Phase:", "ობტურატორის ფაზა:"), + ("Size", "ზომა"), + ("Skip", "გამოტოვება"), + ("Snapping", "მიბმა"), + ("Snapping options", "მიბმის პარამეტრები"), + ("Snapshot", "მომენტალური ასლი"), + ("Solo Supported Properties", "მხარდაჭერილი თვისებების სოლო"), + ("Solo Switches: {}", "სოლოს გადამრთველები: {}"), + ("Sort", "სორტირება"), + ("Spacing:", "ინტერვალი:"), + ("Spatial Interpolation:", "სივრცითი ინტერპოლაცია:"), + ("Speed:", "სიჩქარე:"), + ("Start Timecode:", "საწყისი თაიმკოდი:"), + ("Start: {} Duration: {}", "დასაწყისი: {} ხანგრძლივობა: {}"), + ("Step {} of {}: {}", "ნაბიჯი {} / {}: {}"), + ("Stop", "შეწყვეტა"), + ("Stop {} at {}°", "გადასვლის წერტილი {} — {}°"), + ("Stored in: {}", "შენახულია: {}"), + ("Stretch", "გაწელვა"), + ("Stretch Factor:", "გაწელვის კოეფიციენტი:"), + ("Stylistic Sets", "სტილისტური ნაკრებები"), + ("Subpixel Positioning", "სუბპიქსელური პოზიციონირება"), + ("Swap Colors", "ფერების გაცვლა"), + ("Swap Paint Colors (X)", "ხატვის ფერების გაცვლა (X)"), + ("Sync with System", "სისტემასთან სინქრონიზაცია"), + ("System Default", "სისტემის ნაგულისხმევი"), + ("Table", "ცხრილი"), + ("Template name", "შაბლონის სახელი"), + ("Template: {}\n{}", "შაბლონი: {}\n{}"), + ("Temporal Interpolation:", "დროითი ინტერპოლაცია:"), + ("The Media Browser is not available here (use File ▸ Import)", "მედიის ბრაუზერი აქ მიუწვდომელია (გამოიყენეთ ფაილი ▸ იმპორტი)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "OpenEXR შრე წითლად, მწვანედ, ლურჯად და ალფად"), + ("The render queue is empty.", "რენდერის რიგი ცარიელია."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "ამ მეზობელ საკვანძო კადრებს შორის ინტერპოლაცია შეუძლებელია, რადგან მათ გრადიენტის გადასვლის წერტილების განსხვავებული რაოდენობა აქვთ.", + ), + ("This ScriptUI panel has closed", "ეს ScriptUI პანელი დაიხურა"), + ("This change will create a new solid for this layer.", "ეს ცვლილება ამ შრისთვის ახალ სოლიდს შექმნის."), + ("Time", "დრო"), + ("Time Stretch…", "დროში გაწელვა…"), + ("Time:", "დრო:"), + ("Time: {}", "დრო: {}"), + ("Title/Action Safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Title/action safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Toggle Switches / Modes", "გადამრთველების / რეჟიმების გადართვა"), + ("Track Name:", "ტრეკის სახელი:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "გაწვრთნილი მოდელები ხელმისაწვდომია დესკტოპის აპში; ბრაუზერი კლასიკურ ძრავს იყენებს.", + ), + ("Transform", "გარდაქმნა"), + ("Transparency grid", "გამჭვირვალობის ბადე"), + ("Tutorial complete", "გაკვეთილი დასრულდა"), + ("Two-Node Camera", "ორკვანძიანი კამერა"), + ("Type", "ტიპი"), + ("Type a command or effect…", "აკრიფეთ ბრძანება ან ეფექტი…"), + ("URL:", "URL:"), + ("Undock Panel", "პანელის მოხსნა"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move Panel to Main Window", "პანელის მთავარ ფანჯარაში გადატანა"), + ("Unfreeze", "გაყინვის მოხსნა"), + ("Unlink {}", "{}-ის კავშირის გაწყვეტა"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "გამოიყენეთ ეს ვარიანტი მონიშნული შრეების ერთად ახალ შუალედურ კომპოზიციაში მოსათავსებლად.", + ), + ("VR Environments", "VR გარემოები"), + ("Value:", "მნიშვნელობა:"), + ("Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("Video Codec: {}", "ვიდეო კოდეკი: {}"), + ("View:", "ხედი:"), + ("Welcome to EffectCraft", "მოგესალმებათ EffectCraft"), + ("Width:", "სიგანე:"), + ("You can undo this.", "ამის დაბრუნება შეგიძლიათ."), + ("You can't undo this.", "ამის დაბრუნება შეუძლებელია."), + ("Your changes will be lost if you don't save them.", "თუ ცვლილებებს არ შეინახავთ, ისინი დაიკარგება."), + ("Zoom", "მასშტაბი"), + ("built-in", "ჩაშენებული"), + ("frames per second", "კადრი წამში"), + ("template name", "შაბლონის სახელი"), + ("{} contributors · {} commits", "მონაწილე: {} · კომიტი: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB 8bpc კადრზე"), + ("{} — coming soon", "{} — მალე"), + ("{} — double-click to open, {}-click to reveal", "{} — ორჯერ დაწკაპეთ გასახსნელად, {}-დაწკაპუნება საჩვენებლად"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-ით გადაათრიეთ სამაგრი მისი მოძრაობის ჩასაწერად"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} გამოიყენება:"), + ("3D Reference Axes", "3D საორიენტაციო ღერძები"), + ("Affect all layers that use this solid", "ზემოქმედება ყველა შრეზე, რომელიც ამ სოლიდს იყენებს"), + ("Choose grid and guide options", "ბადისა და დამხმარე ხაზების პარამეტრების არჩევა"), + ("Colorize", "გაფერადება"), + ("Custom", "მორგებული"), + ("Custom...", "მორგებული..."), + ("Fit", "ჩატევა"), + ("Fit up to 100%", "ჩატევა 100%-მდე"), + ("Full", "სრული"), + ("Half", "ნახევარი"), + ("Only this layer uses this solid.", "ამ სოლიდს მხოლოდ ეს შრე იყენებს."), + ("Proportional Grid", "პროპორციული ბადე"), + ("Quarter", "მეოთხედი"), + ("Region of Interest", "ინტერესის არე"), + ("Reset Exposure", "ექსპოზიციის ჩამოყრა"), + ("Show Channel and Color Management Settings", "არხისა და ფერთა მართვის პარამეტრების ჩვენება"), + ("Show Snapshot (hold)", "მომენტალური ასლის ჩვენება (დაჭერით)"), + ("Take Snapshot", "მომენტალური ასლის გადაღება"), + ("Third", "მესამედი"), + ("Toggle Mask and Shape Path Visibility", "ნიღბისა და ფიგურის ტრაექტორიის ხილვადობის გადართვა"), + ("Toggle Transparency Grid", "გამჭვირვალობის ბადის გადართვა"), + ("16 bits per channel", "16 ბიტი არხზე"), + ("32 bits per channel (float)", "32 ბიტი არხზე (მცურავი)"), + ("8 bits per channel", "8 ბიტი არხზე"), + ("Adaptive Resolution", "ადაპტური გარჩევადობა"), + ("Adobe-style built-in", "ჩაშენებული, Adobe-ს სტილის"), + ("Blue", "ლურჯი"), + ("Current Settings", "მიმდინარე პარამეტრები"), + ("Disable Per-character 3D", "თითოეული ასოს 3D-ს გამორთვა"), + ("Draft", "მონახაზი"), + ("Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("Fast Draft", "სწრაფი მონახაზი"), + ("Fast Previews (Draft 3D, Faster Effects)", "სწრაფი წინასწარი ნახვა (3D მონახაზი, უფრო სწრაფი ეფექტები)"), + ("Green", "მწვანე"), + ("Import Image Sequence", "გამოსახულებების თანმიმდევრობის იმპორტი"), + ("Import PDF / Illustrator File", "PDF / Illustrator ფაილის იმპორტი"), + ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), + ("Interpret Unlabeled Alpha As", "არამონიშნული ალფას ინტერპრეტაცია როგორც"), + ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), + ("Mercury GPU Acceleration ({})", "Mercury GPU აჩქარება ({})"), + ("Mercury Software Only", "მხოლოდ Mercury Software"), + ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), + ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), + ("OpenGL (compatibility)", "OpenGL (თავსებადობა)"), + ("Opus Bitrate: {} kbps", "Opus-ის ბიტრეიტი: {} kbps"), + ("RGB Straight", "RGB Straight"), + ("Red", "წითელი"), + ("Tone map", "ტონური რუკა"), + ("Use In-Window Menu Bar on macOS", "ფანჯრის შიდა მენიუს ზოლის გამოყენება macOS-ზე"), + ("Wireframe", "კარკასი"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (ნაგულისხმევი)"), + ("New Project", "ახალი პროექტი"), + ("Open Project…", "პროექტის გახსნა…"), + ("Templates", "შაბლონები"), + ("EffectCraft home page", "EffectCraft-ის საწყისი გვერდი"), + ("EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("Motion graphics and visual effects · version {}", "მოძრავი გრაფიკა და ვიზუალური ეფექტები · ვერსია {}"), + ("New Composition", "ახალი კომპოზიცია"), + ("Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), + ("Import Footage…", "ვიდეომასალის იმპორტი…"), + ("Filter recent files", "ბოლო ფაილების გაფილტვრა"), + ("NAME", "სახელი"), + ("OPENED", "გახსნილია"), + ("SIZE", "ზომა"), + ("KIND", "სახეობა"), + ("Your recent files will show here", "ბოლოდროინდელი ფაილები აქ გამოჩნდება"), + ("Create a new project, or open the demo project to look around.", "შექმენით ახალი პროექტი, ან გახსენით სადემონსტრაციო პროექტი გასაცნობად."), + ("No recent files match the filter", "ფილტრს ბოლოდროინდელი ფაილები არ შეესაბამება"), + ("Try another name or folder.", "სცადეთ სხვა სახელი ან საქაღალდე."), + ("Just now", "ახლახან"), + ("Yesterday", "გუშინ"), + ("{} minute ago", "{} წუთის წინ"), + ("{} minutes ago", "{} წუთის წინ"), + ("{} hour ago", "{} საათის წინ"), + ("{} hours ago", "{} საათის წინ"), + ("{} day ago", "{} დღის წინ"), + ("{} days ago", "{} დღის წინ"), + ("EffectCraft project", "EffectCraft-ის პროექტი"), + ("After Effects project", "After Effects-ის პროექტი"), + ("Lottie animation", "Lottie ანიმაცია"), + ("Project", "პროექტი"), + ("General", "ზოგადი"), + ("Language", "ენა"), + ("Levels of Undo", "დაბრუნების დონეები"), + ("Path Point and Handle Size", "ტრაექტორიის წერტილისა და სახელურის ზომა"), + ("Recent Projects Shown", "ნაჩვენები ბოლო პროექტები"), + ("Options", "პარამეტრები"), + ("Show Tool Tips", "მინიშნებების ჩვენება"), + ("Create Layers at Composition Start Time", "შრეების შექმნა კომპოზიციის დაწყების დროზე"), + ("Switches Affect Nested Comps", "გადამრთველები მოქმედებს ჩადგმულ კომპოზიციებზე"), + ("Default Spatial Interpolation to Linear", "ნაგულისხმევი სივრცითი ინტერპოლაცია: წრფივი"), + ( + "Preserve Constant Vertex and Feather Point Count when Editing Masks", + "ნიღბების რედაქტირებისას წვეროებისა და შერბილების წერტილების რაოდენობის შენარჩუნება", + ), + ("Synchronize Time of All Related Items", "ყველა დაკავშირებული ელემენტის დროის სინქრონიზაცია"), + ("Expression Pick Whip Writes Compact English", "ექსპრესიის ბმის სპირალი წერს კომპაქტურ ინგლისურს"), + ("Create Split Layers Above Original Layer", "დაყოფილი შრეების შექმნა საწყისი შრის ზემოთ"), + ("Use System Color Picker", "სისტემის ფერის ამომრჩევის გამოყენება"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft ყოველთვის საკუთარ ფერის ამომრჩევს იყენებს (ერთნაირს ყველა პლატფორმაზე და ვებში).", + ), + ("Startup & Repair", "გაშვება და აღდგენა"), + ("Show Home Screen When Launching", "საწყისი ეკრანის ჩვენება გაშვებისას"), + ("Show Home Screen When Opening a Project", "საწყისი ეკრანის ჩვენება პროექტის გახსნისას"), + ("Offer to Open the Latest Auto-Save After a Crash", "ავარიის შემდეგ ბოლო ავტოშენახვის გახსნის შეთავაზება"), + ("Window Graphics", "ფანჯრის გრაფიკა"), + ("Automatic", "ავტომატური"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ფანჯრის გრაფიკა მოქმედებს შემდეგი გაშვებიდან. თუ გრაფიკის დრაივერი EffectCraft-ს ფანჯრის გახსნისას აჩერებს, შემდეგი გაშვება OpenGL-ზე გადაერთვება.", + ), + ("Repair", "აღდგენა"), + ("Reset Settings", "პარამეტრების ჩამოყრა"), + ("Reset Keyboard Shortcuts", "კლავიატურის მალსახმობების ჩამოყრა"), + ("Empty Disk Cache", "დისკის ქეშის გასუფთავება"), + ("New Project Loads Template", "ახალი პროექტი შაბლონს ტვირთავს"), + ("Template Project", "შაბლონი პროექტი"), + ("Auto-Save", "ავტოშენახვა"), + ("Automatically Save Projects", "პროექტების ავტომატური შენახვა"), + ("Save Every", "შენახვა ყოველ"), + ("Minutes", "წუთში"), + ("Maximum Project Versions", "პროექტის ვერსიების მაქსიმალური რაოდენობა"), + ("Auto-Save Location", "ავტოშენახვის მდებარეობა"), + ("Next to Project", "პროექტის გვერდით"), + ("Custom Location", "სხვა მდებარეობა"), + ("Save When Starting Render Queue", "შენახვა რენდერის რიგის დაწყებისას"), + ("Save Now", "ახლავე შენახვა"), + ("Motion Path", "მოძრაობის ტრაექტორია"), + ("All Keyframes", "ყველა საკვანძო კადრი"), + ("No Motion Path", "მოძრაობის ტრაექტორიის გარეშე"), + ("No More Than (Seconds)", "არაუმეტეს (წამი)"), + ("No More Than (Keyframes)", "არაუმეტეს (საკვანძო კადრი)"), + ("Seconds", "წამი"), + ("Keyframes", "საკვანძო კადრები"), + ("Display", "ეკრანი"), + ("Show Rendering Progress in Info Panel and Flowchart", "რენდერის პროგრესის ჩვენება ინფორმაციის პანელსა და ბლოკ-სქემაში"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "კომპოზიციის, შრისა და ვიდეომასალის პანელების აპარატული აჩქარება"), + ("Panels are always drawn by the GPU (wgpu).", "პანელებს ყოველთვის GPU ხატავს (wgpu)."), + ("Previews", "წინასწარი ნახვა"), + ("Adaptive Resolution Limit", "ადაპტური გარჩევადობის ზღვარი"), + ("Show Internal Wireframes", "შიდა კარკასების ჩვენება"), + ("Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბირების ხარისხი"), + ("Faster", "უფრო სწრაფი"), + ("More Accurate", "უფრო ზუსტი"), + ("Display Color Space", "ეკრანის ფერთა სივრცე"), + ("GPU Information...", "ინფორმაცია GPU-ს შესახებ..."), + ("Appearance", "იერსახე"), + ("Brightness", "სიკაშკაშე"), + ("UI Scale", "ინტერფეისის მასშტაბი"), + ("Labels and Colors", "იარლიყები და ფერები"), + ("Use Label Color for Layer Handles and Paths", "იარლიყის ფერის გამოყენება შრის სახელურებისა და ტრაექტორიებისთვის"), + ("Use Label Color for Related Tabs", "იარლიყის ფერის გამოყენება დაკავშირებული ჩანართებისთვის"), + ("Cycle Mask Colors", "ნიღბის ფერების მონაცვლეობა"), + ("Use Gradients", "გრადიენტების გამოყენება"), + ("Menu Bar", "მენიუს ზოლი"), + ("Grids & Guides", "ბადე და დამხმარე ხაზები"), + ("Gridline Every", "ბადის ხაზი ყოველ"), + ("Subdivisions", "ქვედანაყოფები"), + ("Style", "სტილი"), + ("Lines", "ხაზები"), + ("Dashed Lines", "წყვეტილი ხაზები"), + ("Dots", "წერტილები"), + ("Horizontal", "ჰორიზონტალური"), + ("Vertical", "ვერტიკალური"), + ("Safe Margins", "უსაფრთხო ზღვრები"), + ("Action-safe", "მოქმედების უსაფრთხო არე"), + ("Title-safe", "ტიტრების უსაფრთხო არე"), + ("Labels", "იარლიყები"), + ("Text Engine", "ტექსტის ძრავა"), + ("Latin", "ლათინური"), + ("South Asian and Middle Eastern", "სამხრეთაზიური და ახლო აღმოსავლური"), + ("Show Font Preview", "შრიფტის წინასწარი ნახვის ჩვენება"), + ("Number of Recent Fonts to Display", "საჩვენებელი ბოლო შრიფტების რაოდენობა"), + ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), + ("Still Footage", "უძრავი ვიდეომასალა"), + ("Length of Composition", "კომპოზიციის სიგრძე"), + ("Duration (Seconds)", "ხანგრძლივობა (წამი)"), + ("Still Duration", "უძრავი კადრის ხანგრძლივობა"), + ("Sequence Footage", "სეკვენციური ვიდეომასალა"), + ("Report Missing Frames", "გამოტოვებული კადრების შეტყობინება"), + ("Automatically Reload Footage", "ვიდეომასალის ავტომატური ხელახლა ჩატვირთვა"), + ("Off", "გამორთული"), + ("Non-Sequence Footage", "არასეკვენციური ვიდეომასალა"), + ("All Footage", "მთელი ვიდეომასალა"), + ("Ask User", "მომხმარებლისთვის კითხვა"), + ("Guess", "ვარაუდი"), + ("Ignore", "უგულებელყოფა"), + ("Straight", "პირდაპირი"), + ("Premultiplied", "წინასწარ გამრავლებული"), + ("Default Drag Import As", "გადათრევით იმპორტი ნაგულისხმევად როგორც"), + ("Footage", "ვიდეომასალა"), + ("Composition - Retain Layer Sizes", "კომპოზიცია - შრეების ზომების შენარჩუნებით"), + ("Export", "ექსპორტი"), + ("Default Output Folder", "ნაგულისხმევი გამოტანის საქაღალდე"), + ("Segment Sequences", "სეკვენციების სეგმენტებად დაყოფა"), + ("Files per Segment", "ფაილი სეგმენტში"), + ("files", "ფაილი"), + ("Segment Movie Files", "ვიდეოფაილების სეგმენტებად დაყოფა"), + ("Segment Size", "სეგმენტის ზომა"), + ("Append Bit Depth to File Name", "ბიტური სიღრმის ფაილის სახელზე დამატება"), + ("Audio", "აუდიო"), + ("Audio Hardware", "აუდიოაპარატურა"), + ("Preview Sample Rate", "წინასწარი ნახვის დისკრეტიზაციის სიხშირე"), + ("Audio Output Mapping", "აუდიოს გამოტანის ასახვა"), + ("Left", "მარცხენა"), + ("Right", "მარჯვენა"), + ("channel", "არხი"), + ("Disk", "დისკი"), + ("Disk Cache", "დისკის ქეში"), + ("Enable Disk Cache", "დისკის ქეშის ჩართვა"), + ("Maximum Disk Cache Size", "დისკის ქეშის მაქსიმალური ზომა"), + ("Disk Cache Folder", "დისკის ქეშის საქაღალდე"), + ("Media Cache", "მედიაქეში"), + ("Database and Cache Folder", "მონაცემთა ბაზისა და ქეშის საქაღალდე"), + ("Conformed Audio Folder", "მისადაგებული აუდიოს საქაღალდე"), + ("Memory & CPU", "მეხსიერება და CPU"), + ("RAM Reserved for Other Applications", "სხვა აპლიკაციებისთვის დაჯავშნული RAM"), + ("Cache Budgets", "ქეშის ლიმიტები"), + ("Layer Cache", "შრეების ქეში"), + ("Footage Frame Cache", "ვიდეომასალის კადრების ქეში"), + ("Preview (RAM) Cache", "წინასწარი ნახვის (RAM) ქეში"), + ("Reduce Cache Size When System Is Low on Memory", "ქეშის ზომის შემცირება, როცა სისტემას მეხსიერება აკლია"), + ("Video", "ვიდეო"), + ("Enable Video Preview Output", "ვიდეოს წინასწარი ნახვის გამოტანის ჩართვა"), + ("Video Device", "ვიდეომოწყობილობა"), + ("Floating Window", "მცურავი ფანჯარა"), + ("Full Screen", "მთელ ეკრანზე"), + ("Video Output During Playback", "ვიდეოს გამოტანა დაკვრისას"), + ("Mirror on Computer Monitor", "დუბლირება კომპიუტერის მონიტორზე"), + ("Disable Video Output When in Background", "ვიდეოს გამოტანის გამორთვა ფონურ რეჟიმში"), + ("Default 3D Renderer", "ნაგულისხმევი 3D რენდერერი"), + ("Show 3D Reference Axes", "3D საორიენტაციო ღერძების ჩვენება"), + ("Extended Viewer", "გაფართოებული დამთვალიერებელი"), + ("Realtime Shadows in Draft", "რეალურ დროში ჩრდილები მონახაზში"), + ("Scripting & Expressions", "სკრიპტები და ექსპრესიები"), + ("Application Scripting", "აპლიკაციის სკრიპტები"), + ("Allow Scripts to Write Files and Access Network", "სკრიპტებისთვის ფაილების ჩაწერისა და ქსელთან წვდომის დაშვება"), + ("Warn User When Executing Files", "მომხმარებლის გაფრთხილება ფაილების გაშვებისას"), + ("Enable JavaScript Debugger", "JavaScript-ის გამმართველის ჩართვა"), + ("Expressions Editor", "ექსპრესიების რედაქტორი"), + ("Font Size", "შრიფტის ზომა"), + ("Syntax Highlighting", "სინტაქსის გამოკვეთა"), + ("Line Numbers", "სტრიქონების ნომრები"), + ("Auto-complete", "ავტოდასრულება"), + ("Bracket Matching", "ფრჩხილების დაწყვილება"), + ("Word Wrap", "სიტყვების გადატანა"), + ("Show Expression Error Banner", "ექსპრესიის შეცდომის ზოლის ჩვენება"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "ექსპრესიები JavaScript-ის ძრავას იყენებს. სკრიპტის შეცდომები ფაილსა და სტრიქონს სკრიპტის კონსოლში აჩვენებს; ნაბიჯ-ნაბიჯ გამმართველი არ არის.", + ), + ("Roto Brush", "როტო-ფუნჯი"), + ("Segmentation Model", "სეგმენტაციის მოდელი"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "როტო-ფუნჯი 2.0 და 3.0 აქ არჩეულ მოდელს იყენებს (ვერსია 1.0 ყოველთვის კლასიკურ ძრავას იყენებს). მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Face Tracking", "სახის ტრეკინგი"), + ("Face Model", "სახის მოდელი"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ტრეკერის პანელში სახის ტრეკინგი (მხოლოდ კონტური და დეტალური ნიშნები) აქ არჩეულ მოდელს იყენებს. მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Match System", "სისტემის მიხედვით"), + ("Default", "ნაგულისხმევი"), + ("Stored, but not used by EffectCraft yet", "შენახულია, მაგრამ EffectCraft ჯერ არ იყენებს"), + ("Total Offset", "ჯამური წანაცვლება"), + ("Per Layer", "შრიდან შრემდე"), + ("Track", "ტრეკინგი"), +]; + +/// Georgian rows for interface text no other catalog has yet: strings the panels already send +/// through [`tr`] (form fields and titles, drop messages, screen-reader names) and the labels of +/// the Timeline's context menus. Kept apart from [`GEORGIAN`] so that table can keep listing +/// exactly the Chinese catalogs' sources; the tests hold these to the same rules. +const GEORGIAN_PANELS: &[(&str, &str)] = &[ + ("Search project", "პროექტში ძიება"), + ("Select an item to see its details", "მონიშნეთ ელემენტი მისი დეტალების სანახავად"), + ("Media Duration", "მედიის ხანგრძლივობა"), + ("Frame Rate", "კადრების სიხშირე"), + ("File Path", "ფაილის მისამართი"), + ("Comment", "კომენტარი"), + ("A/V Features", "A/V ფუნქციები"), + ("Keys", "საკვანძო კადრები"), + ("Layer Name", "შრის სახელი"), + ("Source Name", "წყაროს სახელი"), + ("Switches", "გადამრთველები"), + ("Modes", "რეჟიმები"), + ("Track Matte", "ტრეკ-მატი"), + ("Parent & Link", "მშობელი და ბმა"), + ("In", "შესვლა"), + ("Out", "გამოსვლა"), + ("Duration", "ხანგრძლივობა"), + ("Original color: click to go back to it", "საწყისი ფერი: დაწკაპეთ მასზე დასაბრუნებლად"), + ("Resize floating panel", "მცურავი პანელის ზომის შეცვლა"), + ("Render every n-th pixel", "ყოველი n-ური პიქსელის რენდერი"), + ("Color (#rrggbb)", "ფერი (#rrggbb)"), + ("Workspace", "სამუშაო სივრცე"), + ("Rename to", "ახალი სახელი"), + ("Open", "გახსნა"), + ("Custom Resolution", "მორგებული გარჩევადობა"), + ("Panel Background Color", "პანელის ფონის ფერი"), + ("New Workspace", "ახალი სამუშაო სივრცე"), + ("Edit Workspaces", "სამუშაო სივრცეების რედაქტირება"), + ("A script wants to open a file", "სკრიპტს ფაილის გახსნა სურს"), + ("Drop file to import", "ჩააგდეთ ფაილი იმპორტისთვის"), + ("Drop {} files to import", "ჩააგდეთ ფაილები იმპორტისთვის: {}"), + ( + "Skipped {} dropped file(s) without a local path. Use File ▸ Import ▸ File…", + "გამოტოვებულია ჩაგდებული ფაილი ლოკალური მისამართის გარეშე: {}. გამოიყენეთ ფაილი ▸ იმპორტი ▸ ფაილი…", + ), + ("Only one project can be opened per drop; skipped {} more", "ერთ ჩაგდებაზე მხოლოდ ერთი პროექტი იხსნება; გამოტოვებულია კიდევ: {}"), + ("Cannot open dropped project {}: {}", "ჩაგდებული პროექტის გახსნა შეუძლებელია, {}: {}"), + ("Cannot import dropped files: {}", "ჩაგდებული ფაილების იმპორტი შეუძლებელია: {}"), + ("A built-in preset has this name", "ამ სახელით ჩაშენებული პრესეტი არსებობს"), + ("Replaces your preset with this name", "ჩაანაცვლებს თქვენს პრესეტს ამ სახელით"), + ("Save Preset…", "პრესეტის შენახვა…"), + ("Delete Preset", "პრესეტის წაშლა"), + ("Null Settings", "ნულოვანი ობიექტის პარამეტრები"), + ("Click to sample text {}", "დაწკაპეთ ტექსტის ნიმუშის ასაღებად {}"), + ("Force alphabetical order", "ანბანური რიგის იძულება"), + ("Frame rate (fps)", "კადრების სიხშირე (fps)"), + ( + "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage.", + "კადრების სიხშირე, ალფა და საწყისი კადრი მოგვიანებით შეცვალეთ: ფაილი ▸ ვიდეომასალის ინტერპრეტაცია.", + ), + ("Import Kind", "იმპორტის სახე"), + ("Page", "გვერდი"), + ("Delay (s)", "დაყოვნება (წმ)"), + ("Amount (%)", "რაოდენობა (%)"), + ("Cascade (farther pins trail longer)", "კასკადი (შორეული სამაგრები უფრო დიდხანს ჩამორჩება)"), + ("Speed (%)", "სიჩქარე (%)"), + ("Smoothing", "დაგლუვება"), + ("Use Draft Deformation", "მონახაზის დეფორმაციის გამოყენება"), + ("Show Mesh", "ბადის ჩვენება"), + ("Scale X (%)", "მასშტაბი X (%)"), + ("Scale Y (%)", "მასშტაბი Y (%)"), + ("Rotation (degrees)", "შემობრუნება (გრადუსი)"), + ("Move X", "გადაადგილება X"), + ("Move Y", "გადაადგილება Y"), + ("Top", "ზემოდან"), + ("Width", "სიგანე"), + ("Height", "სიმაღლე"), + ("Shape", "ფიგურა"), + ("Mode", "რეჟიმი"), + ("Frame", "კადრი"), + ("Orientation", "ორიენტაცია"), + ("Position (pixels)", "პოზიცია (პიქსელი)"), + ("Position (percent of the composition)", "პოზიცია (კომპოზიციის პროცენტი)"), + ("Create", "შექმნა"), + ("Use Dimensions From", "ზომების აღება"), + ("Still Duration (seconds)", "უძრავი კადრის ხანგრძლივობა (წამი)"), + ("Add to Render Queue", "რენდერის რიგში დამატება"), + ("Sequence Layers", "შრეების თანმიმდევრობით განლაგება"), + ("Overlap", "გადაფარვა"), + ("Duration (seconds)", "ხანგრძლივობა (წამი)"), + ("Transition", "გადასვლა"), + ("Matte color (premultiplied)", "მატის ფერი (წინასწარ გამრავლებული)"), + ("Invert Alpha", "ალფას ინვერსია"), + ("Assume this frame rate", "ამ კადრების სიხშირის გამოყენება"), + ("Separate Fields", "ნახევარკადრების გაყოფა"), + ("Pixel Aspect Ratio", "პიქსელის გვერდების თანაფარდობა"), + ("Loop (times)", "ციკლი (ჯერ)"), + ("Assign Profile", "პროფილის მინიჭება"), + ("Preserve RGB", "RGB-ის შენარჩუნება"), + ("Interpret As Linear Light", "წრფივ სინათლედ ინტერპრეტაცია"), + ("Start Timecode", "საწყისი თაიმკოდი"), + ("Override Start", "დასაწყისის ხელით მითითება"), + ("Depth", "სიღრმე"), + ("Time display style", "დროის ჩვენების სტილი"), + ("Color engine", "ფერთა ძრავა"), + ("Working space", "სამუშაო სივრცე"), + ("Working gamma", "სამუშაო გამა"), + ("Linearize working space", "სამუშაო სივრცის წრფივად გარდაქმნა"), + ("Blend colors using 1.0 gamma", "ფერების შერევა 1.0 გამით"), + ("HDR on SDR displays and outputs", "HDR SDR ეკრანებსა და გამოტანებზე"), + ("Output color space", "გამოტანის ფერთა სივრცე"), + ("Video rendering and effects", "ვიდეოს რენდერი და ეფექტები"), + ("Time Span", "დროის მონაკვეთი"), + ("Invert", "ინვერსია"), + ("Blur (pixels before auto-trace)", "გაბუნდოვნება (პიქსელი, ვექტორიზაციამდე)"), + ("Tolerance (pixels)", "დაშვება (პიქსელი)"), + ("Threshold (%)", "ზღვარი (%)"), + ("Minimum Area (pixels)", "მინიმალური ფართობი (პიქსელი)"), + ("Corner Roundness (%)", "კუთხეების მომრგვალება (%)"), + ("Apply To New Layer", "ახალ შრეზე გამოყენება"), + ("Action", "მოქმედება"), + ("Sensitivity threshold (0–1, lower finds more)", "მგრძნობელობის ზღვარი (0–1, ნაკლები მეტს პოულობს)"), + ("Data", "მონაცემები"), + ("Time field (empty: automatic)", "დროის ველი (ცარიელი: ავტომატური)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ვიდეოს დასაწყისი (ISO თარიღი-დრო, hh:mm:ss, თაიმკოდი; ცარიელი: ფაილის თარიღი)"), + ("First sample at (comp seconds)", "პირველი ნიმუში (კომპოზიციის წამი)"), + ( + "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written.", + "იწერს Final Cut Pro XML-ს (.xml), რომელსაც Premiere Pro ხსნის ბრძანებით File ▸ Import. მშობლიური .prproj ფაილები არ იწერება.", + ), + ("File", "ფაილი"), + ("Format", "ფორმატი"), + ("Pre-render (ProRes 4444)", "წინასწარი რენდერი (ProRes 4444)"), + ("Precomps", "ჩადგმული კომპოზიციები"), + ("Frame rate", "კადრების სიხშირე"), + ("Description", "აღწერა"), + ("Category", "კატეგორია"), + ("Embed footage files", "ვიდეომასალის ფაილების ჩაშენება"), + ("Embed up to (MB)", "ჩაშენება მაქსიმუმ (MB)"), + ("Use the sRGB curve (ignore Gamma)", "sRGB მრუდის გამოყენება (გამის უგულებელყოფა)"), + ("ICC profile (replaces the numbers)", "ICC პროფილი (ცვლის რიცხვებს)"), + ("Rectangle", "მართკუთხედი"), + ("Ellipse", "ელიფსი"), + ("Orient Along Path", "ორიენტაცია ტრაექტორიის გასწვრივ"), + ("Orient Towards Camera", "ორიენტაცია კამერისკენ"), + ("Orient Towards Point of Interest", "ორიენტაცია ინტერესის წერტილისკენ"), + ("Single Composition", "ერთი კომპოზიცია"), + ("Multiple Compositions", "რამდენიმე კომპოზიცია"), + ("Dissolve Front Layer", "წინა შრის გადადნობა"), + ("Cross Dissolve Front and Back Layers", "წინა და უკანა შრეების ჯვარედინი გადადნობა"), + ("Interpret Straight - Unmatted", "პირდაპირად ინტერპრეტაცია - მატის გარეშე"), + ("Interpret Premultiplied - Matted With Color", "წინასწარ გამრავლებულად ინტერპრეტაცია - ფერით მატირებული"), + ("Upper Field First", "ჯერ ზედა ნახევარკადრი"), + ("Lower Field First", "ჯერ ქვედა ნახევარკადრი"), + ("Embedded / sRGB", "ჩაშენებული / sRGB"), + ("Use Source File Timecode", "წყარო ფაილის თაიმკოდის გამოყენება"), + ("Timecode", "თაიმკოდი"), + ("Frames", "კადრები"), + ("Feet + Frames (35mm)", "ფუტი + კადრი (35 მმ)"), + ("Feet + Frames (16mm)", "ფუტი + კადრი (16 მმ)"), + ("Clip", "მოჭრა"), + ("Compand", "კომპანდირება"), + ("Current Frame", "მიმდინარე კადრი"), + ("Work Area", "სამუშაო არე"), + ("Luminance", "სიკაშკაშე"), + ("Create Markers", "მარკერების შექმნა"), + ("Split Layers", "შრეების დაყოფა"), + ("Split and Precompose", "დაყოფა და კომპოზიციაში მოთავსება"), + ("CMX 3600 EDL (.edl, V1 only)", "CMX 3600 EDL (.edl, მხოლოდ V1)"), + ("OMF (.omf, audio)", "OMF (.omf, აუდიო)"), + ("Layers Premiere can't show (text, shapes, effects…)", "შრეები, რომლებსაც Premiere ვერ აჩვენებს (ტექსტი, ფიგურები, ეფექტები…)"), + ("All layers", "ყველა შრე"), + ("None (leave them out)", "არცერთი (გამოტოვება)"), + ("Nested sequences", "ჩადგმული სეკვენციები"), + ("Pre-render", "წინასწარი რენდერი"), + ("Puppet Follow-Through", "მარიონეტის ინერციული მოძრაობა"), + ("Puppet Record Options", "მარიონეტის ჩაწერის პარამეტრები"), + ("Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("Mask Shape", "ნიღბის ფორმა"), + ("Auto-Orientation", "ავტოორიენტაცია"), + ("Go to Time", "დროზე გადასვლა"), + ("Add Guide", "დამხმარე ხაზის დამატება"), + ("Add Percentage Guide", "პროცენტული დამხმარე ხაზის დამატება"), + ("New Composition from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Project Settings", "პროექტის პარამეტრები"), + ("Auto-trace", "ავტომატური ვექტორიზაცია"), + ("Scene Edit Detection", "სცენების ჭრილების ამოცნობა"), + ("Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("Export Adobe Premiere Pro Project", "Adobe Premiere Pro-ს პროექტის ექსპორტი"), + ("New Placeholder", "ახალი ჩანაცვლების ობიექტი"), + ("Solid Settings", "სოლიდის პარამეტრები"), + ("Save as Template", "შაბლონად შენახვა"), + ("My Custom RGB", "ჩემი მორგებული RGB"), + ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"), + ("Sort by {}, {}", "დალაგება: {}, {}"), + ("Sort by {}", "დალაგება: {}"), + ("Label of {}: {}", "{}-ის იარლიყი: {}"), + ("Comment of {}: {}", "{}-ის კომენტარი: {}"), + ("Project color depth: {}", "პროექტის ფერის სიღრმე: {}"), + ("Start (s)", "დასაწყისი (წმ)"), + ("Duration (s)", "ხანგრძლივობა (წმ)"), + ("Frames per second", "კადრი წამში"), + ("Bottom", "ქვემოდან"), + ("Bitrate (kbps)", "ბიტრეიტი (kbps)"), + ("Frames between key frames (0 = every 2 s)", "კადრები საკვანძო კადრებს შორის (0 = ყოველ 2 წმ-ში)"), + ("Click in the Composition panel to sample text {}", "დაწკაპეთ კომპოზიციის პანელში ტექსტის ნიმუშის ასაღებად {}"), + ("Video preview off: audio only", "ვიდეოს წინასწარი ნახვა გამორთულია: მხოლოდ აუდიო"), + ("Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("Effects", "ეფექტები"), + ("Video preview is off (Preview panel): audio only", "ვიდეოს წინასწარი ნახვა გამორთულია (წინასწარი ნახვის პანელი): მხოლოდ აუდიო"), + ("Solid…", "სოლიდი…"), + ("Freeze on Last Frame", "ბოლო კადრზე გაყინვა"), + ("Edit Value…", "მნიშვნელობის რედაქტირება…"), + ("Remove Expression", "ექსპრესიის მოცილება"), +]; + #[cfg(test)] mod tests { use super::*; @@ -4212,6 +5168,7 @@ mod tests { ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), @@ -4303,6 +5260,8 @@ mod tests { .chain(PORTUGUESE.iter().copied()) .chain(CZECH.iter().copied()) .chain(SPANISH.iter().copied()) + .chain(GEORGIAN.iter().copied()) + .chain(GEORGIAN_PANELS.iter().copied()) .collect() } @@ -4332,6 +5291,15 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); + let ka: Vec<&str> = GEORGIAN.iter().map(|(en, _)| *en).collect(); + assert_eq!(hans, ka, "the Georgian catalog lists different source strings from the Chinese ones"); + let ka_panels: Vec<&str> = GEORGIAN_PANELS.iter().map(|(en, _)| *en).collect(); + let shared: Vec<&str> = ka_panels.iter().copied().filter(|en| hans.contains(en)).collect(); + assert!(shared.is_empty(), "Georgian-only rows the Chinese catalogs have too (move them to GEORGIAN): {shared:?}"); + let mut own = ka_panels.clone(); + own.sort_unstable(); + own.dedup(); + assert_eq!(own.len(), ka_panels.len(), "a Georgian-only source string is listed twice"); // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH), ("Spanish", SPANISH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); @@ -4403,6 +5371,22 @@ mod tests { assert_eq!(tr("Cancel"), "Cancel"); } + /// Georgian reads its own rows; counts stay count-neutral and values keep their order. + #[test] + fn georgian_panels_translate_and_keep_project_content() { + set_current("ka"); + assert_eq!(tr("Composition Name:"), "კომპოზიციის სახელი:"); + assert_eq!(tr("Cancel"), "გაუქმება"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ნაბიჯი 1 / 3: Colour"); + assert_eq!(tr_args("{} minutes ago", &[&5]), "5 წუთის წინ"); + // A Georgian-only row (GEORGIAN_PANELS), which other languages still draw in English. + assert_eq!(tr("Solid Settings"), "სოლიდის პარამეტრები"); + set_current("cs"); + assert_eq!(tr("Solid Settings"), "Solid Settings"); + set_current("en"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 3e0984494..13986ac84 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -206,6 +206,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::georgian(app) { + "სერვისები" } else { "Services" } @@ -489,6 +491,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn georgian_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["სერვისები", "EffectCraft-ის დამალვა", "სხვების დამალვა", "ყველას ჩვენება"]); + } + #[test] fn hide_items_are_predefined_on_macos() { let spec = build(&app()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..e56275cc2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -120,7 +120,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { divider(&p, x0, w, y - 4.0, &t); section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation"); y += ROW_H + 10.0; - let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0)); + // Both buttons grow with their (translated) labels; English keeps its 116 / 84 px. + let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x; + let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0)); let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click()); p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text); @@ -142,7 +144,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Our own presets (original work, see presets/text_animators.json). - let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0)); + let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0)); let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click()); p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..2d44bb2fc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 624 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | @@ -300,8 +300,8 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie - Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–12 October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian, - Spanish, Brazilian Portuguese, Russian, French, German and Czech, and the panel catalog covers - Japanese, both Chinese, Ukrainian, Brazilian Portuguese and Czech. Next: effect and parameter + Spanish, Brazilian Portuguese, Russian, French, German, Czech and Georgian, and the panel catalog covers + Japanese, both Chinese, Ukrainian, Brazilian Portuguese, Czech and Georgian. Next: effect and parameter names, the remaining panel strings (Spanish, Russian, French, German), Korean and Italian, and native-speaker review of all of them. - Accessibility (keyboard navigation, screen readers, contrast), user documentation and diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..701850477 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599); Georgian: menus, panels and effect names) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -24,9 +24,9 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + counted at 470ea7a3; Georgian has every row the Chinese catalogs have. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese and Georgian (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes @@ -76,13 +76,14 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (3): +Other languages EffectCraft ships (4): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | | Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Georgian | `ka` | 1,510 (≈ 38%): menus 628 rows, panels 882 (the Chinese catalogs' 687 plus 195 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -104,6 +105,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 882 (195 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | diff --git a/docs/preferences.md b/docs/preferences.md index 59b0fc8a9..52fe92279 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br` / `ka`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`, a Georgian one (`ka`, `ka-GE`, `ka_GE.UTF-8`) selects `ka`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From 642eed0802d6516a691c4e589a6983d91adbc665 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 14:39:26 -0600 Subject: [PATCH 101/916] Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434) With several layers selected, typing a value into one dimension (say Z Position) sent the edited layer's whole value with selected: true, and prop.set gave every selected layer that same value: they all took the first layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an offset). prop.set takes dims: with selected and a typed value, the other selected layers get only those components and keep the rest of their own value (Position with Separate Dimensions included). The three panels send the components the edit changed (both for linked Scale). Without dims, agents still set the whole value on every selected layer. Fixes #1434 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/engine/src/commands/prop.rs | 24 ++++++++++- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 5 +++ crates/ui-egui/src/panels/properties.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e0..8f2638182 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { } /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with -/// `offset` (a scrub) the same change from each layer's own value. With `offset` and +/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` +/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, +/// which keep the rest of their own value (#1434). With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { .then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect()); let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len()))); let scalar = p.get("value").is_some_and(Value::is_number); + // Typed into some components only: the others stay each layer's own. + let dims: Option> = match (&delta, p.get("dims").and_then(Value::as_array), &new) { + (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), + _ => None, + }; + let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { + if let (Some(dims), Some(n)) = (&dims, &new) + && let Some(mut c) = current_components(s, cid, olid, ouid) + { + for &d in dims { + if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { + *slot = *v; + } + } + q["value"] = json!(c); + } + }; // `p` aimed at (layer, prop) with its value moved by `k` × the change. let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| { if let Some(d) = &delta @@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { } let mut q = twin_params(p, olid, ouid); shifted(s, &mut q, olid, ouid, k); + typed(s, &mut q, olid, ouid); // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -1229,7 +1249,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c0..a7aa8bace 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() { s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); } + +/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets +/// that component on each of them; their other components stay their own. Before, every +/// selected layer took the edited layer's whole value and they piled up on top of it. +#[test] +fn a_typed_component_applies_only_that_component_to_the_selected_layers() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] { + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap(); + } + s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components(); + let uid = prop(&s, a, "transform/position").uid; + // The panels send the edited layer's value with the typed component, and which one it was. + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap(); + assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]); + assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y"); + assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]); + + // Linked Scale: both edited components go to every layer, Z stays each layer's own. + s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap(); + let scale_uid = prop(&s, a, "transform/scale").uid; + s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap(); + assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]); + + // Position with Separate Dimensions on one of the other layers. + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap(); + let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64(); + assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0)); + + // Without `dims` (agents setting a whole value) every selected layer takes the value as is. + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); + assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..ecfed5a23 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -377,6 +377,11 @@ fn prop_row( let mut nc = c.clone(); nc[d] = nv; set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } } x = vr.max.x + 8.0; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..5cb365137 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = vr.max.x + 10.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..f54b1cd40 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4064,6 +4064,11 @@ fn value_editor( } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..d7e532d76 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,48 @@ 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"); } + +/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X +/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so +/// they stacked on top of it and seemed to disappear. +#[test] +fn typing_one_component_with_several_layers_selected_keeps_their_other_components() { + let (mut h, _) = harness(); + let (a, b, transform) = { + let s = &mut h.state_mut().session; + let mut ids = vec![]; + for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] { + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid; + (ids[0], ids[1], tr) + }; + h.state_mut().ui.timeline.open_layers.insert(a); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state() + .session + .active_comp() + .unwrap() + .layer(effectcraft_engine::project::LayerId(l)) + .unwrap() + .props + .prop("transform/position") + .unwrap() + .value + .components() + }; + let position = + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid; + let p = center(&h, &format!("timeline.prop.{position}.value.2")); + click(&mut h, p, 1); + type_text(&mut h, "-300"); + key(&mut h, Key::Enter); + assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]); + assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y"); +} From e0f25c77bb56d763d3b5f1312f76d0120f06769b Mon Sep 17 00:00:00 2001 From: caldiag Date: Wed, 14 Oct 2026 18:27:25 -0300 Subject: [PATCH 102/916] when double-clicking an effect property, it reveals that effect in the timeline --- crates/ui-egui/src/panels/effect_controls.rs | 5 ++- crates/ui-egui/src/panels/timeline.rs | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..737988802 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -272,7 +272,10 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); - ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + if name_response.double_clicked() && super::timeline::reveal_effect_property(app, layer.id.0, uid) { + app.raise_panel(crate::dock::PanelKind::Timeline); + } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..c7114a28e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1134,6 +1134,21 @@ pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u app.ui.timeline.open_groups.extend(open); } +/// Show an Effect Controls property in its layer's Timeline Effects view. +pub(crate) fn reveal_effect_property(app: &mut EffectcraftApp, layer: u64, prop: u64) -> bool { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return false }; + let Some(fx) = l.effects().map(|g| g.uid) else { return false }; + let path = enclosing_groups(l, prop); + if path.first() != Some(&fx) { + return false; + } + let tl = &mut app.ui.timeline; + tl.open_layers.insert(layer); + tl.layer_reveal.insert(layer, vec!["effects".into()]); + tl.open_groups.extend(path); + true +} + /// After a reveal shortcut that picks properties by state (EE expressions): those inside effects /// on `layers` show under their effect, twirled open. pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) { @@ -4317,6 +4332,32 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + #[test] + fn reveal_effect_property_opens_layer_effect_path_in_timeline() { + let mut app = app(); + let comp = app.session.active_comp().unwrap().clone(); + let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0; + let prop = comp + .layer(LayerId(bx)) + .and_then(|layer| layer.effects()) + .and_then(|effects| effects.groups().next()) + .and_then(|effect| effect.get("blurriness")) + .unwrap() + .uid; + + assert!(reveal_effect_property(&mut app, bx, prop)); + assert!(app.ui.timeline.open_layers.contains(&bx)); + assert_eq!(app.ui.timeline.layer_reveal.get(&bx), Some(&vec!["effects".to_string()])); + + let rows = labels(&app); + let effect = rows.iter().position(|row| row.trim() == ">Gaussian Blur").unwrap(); + assert_eq!(rows.get(effect + 1).map(String::as_str).map(str::trim), Some("Blurriness"), "{rows:?}"); + assert!(rows.iter().any(|row| row.trim() == "Blur Dimensions"), "Effects view shows the effect's other properties too: {rows:?}"); + + let transform_prop = comp.layer(LayerId(bx)).and_then(|layer| layer.transform()).and_then(|group| group.get("position")).unwrap().uid; + assert!(!reveal_effect_property(&mut app, bx, transform_prop), "non-effect properties do not change the Effects view"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); 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 103/916] 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 104/916] #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 7a9c2a8c401271aa3917318400fda80fe5416b00 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:19 -0700 Subject: [PATCH 105/916] Fonts: honor Fontconfig configuration search paths Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/text/src/font_dirs.rs | 147 ++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs index 39714cad7..8004a62c0 100644 --- a/crates/text/src/font_dirs.rs +++ b/crates/text/src/font_dirs.rs @@ -19,6 +19,8 @@ pub(crate) struct FontEnv { pub xdg_config_home: Option, /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. pub fontconfig_file: Option, + /// `FONTCONFIG_PATH`: ordered search folders, before the standard configuration folders. + pub fontconfig_path: Option, /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). pub fontconfig_roots: Vec, /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). @@ -34,6 +36,7 @@ impl FontEnv { xdg_data_dirs: var("XDG_DATA_DIRS"), xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_path: var("FONTCONFIG_PATH"), fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), fixed: [ "/usr/share/fonts", @@ -87,10 +90,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20; /// The font folders named by the fontconfig configuration. pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; - let root = match &env.fontconfig_file { - Some(f) => Some(f.clone()), - None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), - }; + let root = fontconfig_root(env); if let Some(root) = root { st.file(&root, 0); } @@ -102,6 +102,40 @@ pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { st.out } +/// Select the main file as fontconfig does: absolute names bypass the search path; +/// relative names (or `fonts.conf`) use FONTCONFIG_PATH, then the standard folders. +fn fontconfig_root(env: &FontEnv) -> Option { + if let Some(file) = &env.fontconfig_file + && file.is_absolute() + { + return Some(file.clone()); + } + if env.fontconfig_file.is_none() && env.fontconfig_path.is_none() { + return env.fontconfig_roots.iter().find(|p| readable_config(p)).cloned(); + } + let file = env.fontconfig_file.as_deref().unwrap_or_else(|| Path::new("fonts.conf")); + let mut dirs = Vec::new(); + if let Some(path) = &env.fontconfig_path { + let mut paths = std::env::split_paths(path).peekable(); + while let Some(mut dir) = paths.next() { + // Fontconfig ignores a final empty item; an interior empty item names `/`. + if dir.as_os_str().is_empty() { + if paths.peek().is_none() { + continue; + } + dir = PathBuf::from("/"); + } + dirs.push(dir); + } + } + dirs.extend(env.fontconfig_roots.iter().filter_map(|p| p.parent().map(Path::to_path_buf))); + dirs.into_iter().map(|dir| dir.join(file)).find(|p| readable_config(p)) +} + +fn readable_config(path: &Path) -> bool { + path.is_file() && std::fs::File::open(path).is_ok() +} + struct Reader<'a> { env: &'a FontEnv, out: Vec, @@ -371,4 +405,109 @@ mod tests { assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn fontconfig_path_selects_the_first_existing_config_in_search_order() { + let root = temp("config-search"); + let first = root.join("first"); + let second = root.join("second"); + write(&first.join("fonts.conf"), "/first/fonts"); + write(&second.join("fonts.conf"), "/second/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(std::env::join_paths([root.join("missing"), first.clone(), second.clone()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/first/fonts")]); + env.fontconfig_path = Some(std::env::join_paths([second, first]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/second/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_relative_selected_config_uses_the_search_path_and_keeps_relative_includes() { + let root = temp("selected-config"); + let config = root.join("config"); + write(&config.join("chosen.conf"), "conf.d"); + write(&config.join("conf.d/10-fonts.conf"), "../../fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.clone().into_os_string()); + env.fontconfig_file = Some(PathBuf::from("chosen.conf")); + assert_eq!(fontconfig_dirs(&env), [config.join("conf.d/../../fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_absolute_selected_config_bypasses_the_search_path() { + let root = temp("absolute-config"); + write(&root.join("search/fonts.conf"), "/wrong/fonts"); + write(&root.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("search").into_os_string()); + env.fontconfig_file = Some(root.join("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_file = Some(root.join("missing.conf")); + assert!(fontconfig_dirs(&env).is_empty(), "an explicit missing file does not pick another config"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn configuration_search_falls_back_to_standard_folders() { + let root = temp("default-config"); + let config = root.join("etc/fonts"); + write(&config.join("fonts.conf"), "/default/fonts"); + write(&config.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("missing").into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/default/fonts")]); + env.fontconfig_file = Some(PathBuf::from("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_preserves_non_unicode_folder_names() { + use std::os::unix::ffi::OsStrExt; + let root = temp("non-unicode-config"); + let config = root.join(std::ffi::OsStr::from_bytes(b"config-\xff")); + write(&config.join("fonts.conf"), "/configured/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/configured/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_skips_unreadable_files() { + use std::os::unix::fs::PermissionsExt; + struct Cleanup { + root: PathBuf, + file: PathBuf, + permissions: std::fs::Permissions, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.file, self.permissions.clone()); + let _ = std::fs::remove_dir_all(&self.root); + } + } + let root = temp("unreadable-config"); + let first = root.join("first/fonts.conf"); + let second = root.join("second/fonts.conf"); + write(&first, "/unreadable/fonts"); + write(&second, "/readable/fonts"); + let _cleanup = Cleanup { root: root.clone(), file: first.clone(), permissions: std::fs::metadata(&first).unwrap().permissions() }; + std::fs::set_permissions(&first, std::fs::Permissions::from_mode(0)).unwrap(); + if std::fs::File::open(&first).is_ok() { + eprintln!("skipped: this process can read mode-000 files"); + return; + } + let mut env = env_in(&root); + env.fontconfig_roots = vec![first.clone(), second.clone()]; + env.fontconfig_path = Some(std::env::join_paths([first.parent().unwrap(), second.parent().unwrap()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + } } From a3c3b8530c927b198749e5468c669bc873d11f05 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 15:30:59 +0200 Subject: [PATCH 106/916] Text: a variable font's named instances are styles of its family (#1563) The font scan read only whether a face has an fvar table, so a variable family offered one style (its default instance), fontStyle = "Black" picked that face with synthetic bold, and an instance's PostScript name (Figtree-Black, what After Effects scripts use) found no face at all. The scan now reads the named instances (style, axis values, PostScript name, or the one Adobe Technical Note #5902 derives). They are styles of the family in text.fonts and the font menus, a style, PostScript name or full name selects the instance, and layout uses its axis values as the base variations; axes set on the text still win. --- crates/engine/src/tests_text_edit.rs | 60 +++++++ crates/text/src/fonts.rs | 139 ++++++++++++++-- crates/text/src/layout.rs | 44 ++++++ crates/text/src/sfnt.rs | 193 ++++++++++++++++++++++- crates/text/src/variable.rs | 10 +- crates/ui-egui/src/panels/text_panels.rs | 7 +- 6 files changed, 435 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 3e41688d2..243a9c676 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -317,6 +317,66 @@ fn fonts_filter_variable_families() { assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable)); } +/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style +/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name +/// `-{rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn matching_work_past_the_budget_errors_without_doing_it() { + // Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations. + let sels = vec!["rect"; 10_000].join(","); + let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("